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
+19
View File
@@ -0,0 +1,19 @@
import crypto from 'node:crypto';
const KEY_LEN = 64;
// Hash com salt aleatorio (scrypt) — formato armazenado: "salt:hash", ambos hex.
export function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(password, salt, KEY_LEN).toString('hex');
return `${salt}:${hash}`;
}
export function verifyPassword(password, stored) {
const [salt, hash] = String(stored || '').split(':');
if (!salt || !hash) return false;
const expected = Buffer.from(hash, 'hex');
const candidate = crypto.scryptSync(password, salt, expected.length);
return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected);
}
+124
View File
@@ -0,0 +1,124 @@
import { loadDotEnv } from '../env.mjs';
import { openDb } from '../db.mjs';
import { runCapture } from './runner.mjs';
import { createJobIO } from '../jobs/protocol.mjs';
import { randomUUID } from 'node:crypto';
import { createJobLease } from '../jobs/lease.mjs';
// Processo filho de captura: le os parametros da linha em capture_runs,
// resolve nichos/cidades e roda o runner, reportando progresso via stdout.
await loadDotEnv();
const args = new Map(
process.argv.slice(2).map((arg) => {
const [key, value = 'true'] = arg.replace(/^--/, '').split('=');
return [key, value];
})
);
const io = createJobIO();
async function main() {
const runId = Number(args.get('run-id'));
if (!runId) throw new Error('capture-job: --run-id obrigatorio');
let db;
let lease;
try {
db = openDb();
const run = db.prepare('SELECT * FROM capture_runs WHERE id = ?').get(runId);
if (!run) throw new Error(`capture_run #${runId} nao encontrado`);
lease = createJobLease({ db, table: 'capture_runs', id: runId, ownerToken: args.get('owner-token') || randomUUID() });
const saved = JSON.parse(run.params_json);
let segments = db.prepare('SELECT * FROM segments WHERE active = 1 ORDER BY id').all().map((row) => ({
name: row.name,
label: row.label,
interest: row.interest,
queries: JSON.parse(row.queries_json),
cnaeCodes: JSON.parse(row.cnae_codes_json),
overpassTags: JSON.parse(row.overpass_tags_json),
pains: JSON.parse(row.pains_json),
affinity: row.affinity,
products: JSON.parse(row.products_json)
}));
{
// Cada item selecionado pode ser o nome de um nicho pre-configurado, o
// sentinela "all" (todos os pre-configurados), ou um CNAE avulso
// escolhido na busca livre por atividade da Receita (prefixo "cnae:<codigo>").
const wanted = Array.isArray(saved.segments) && saved.segments.length ? saved.segments : ['all'];
const includeAllCurated = wanted.includes('all');
const wantedNames = new Set(wanted.filter((id) => typeof id === 'string' && id !== 'all' && !id.startsWith('cnae:')));
const wantedCnaes = wanted.filter((id) => typeof id === 'string' && id.startsWith('cnae:')).map((id) => id.slice('cnae:'.length));
if (!includeAllCurated) {
segments = segments.filter((s) => wantedNames.has(s.name));
}
if (wantedCnaes.length) {
const placeholders = wantedCnaes.map(() => '?').join(',');
const cnaeRows = db.prepare(`SELECT codigo, descricao FROM cnaes WHERE codigo IN (${placeholders})`).all(...wantedCnaes);
for (const row of cnaeRows) {
segments.push({
name: `cnae_${row.codigo}`,
label: row.descricao,
interest: 'gestao de TI',
queries: [row.descricao],
cnaeCodes: [row.codigo],
overpassTags: [],
pains: ['atendimento', 'vendas'],
affinity: 'media',
products: []
});
}
}
}
let cities = saved.cities;
if (!Array.isArray(cities) || !cities.length || cities[0] === 'all') {
cities = db.prepare('SELECT nome FROM municipios WHERE uf = ? ORDER BY nome')
.all(saved.uf).map((r) => r.nome);
}
const params = {
sources: saved.sources,
uf: saved.uf,
cities,
segments,
limits: saved.limits || { maxPerQuery: 10, queryLimit: 0 }
};
const summary = await runCapture({
db,
runId,
params,
emit: (p) => io.emit(p),
log: (m) => io.log(m),
isCancelled: io.isCancelled
});
io.done({
cancelled: summary.status === 'cancelled',
raw: summary.raw,
new: summary.new,
dup: summary.dup,
status: summary.status
});
} catch (error) {
if (!db) db = openDb();
db.prepare(
"UPDATE capture_runs SET status = 'error', error = ?, finished_at = datetime('now') WHERE id = ? AND status IN ('pending','running')"
).run(String(error.message || error), runId);
throw error;
} finally {
lease?.close();
db?.close();
}
}
main().catch((error) => {
io.fail(error);
process.exit(1);
});
+42
View File
@@ -0,0 +1,42 @@
import { cleanText } from '../lead-engine.mjs';
import { UA_HEADERS, UF_NAMES } from '../sources/common.mjs';
import { fetchJsonWithTimeout } from '../util/fetch.mjs';
// Resolve a bounding box de uma cidade para as consultas Overpass.
// Substitui o antigo cityAreas fixo do config.json: consulta o Nominatim
// uma unica vez por cidade e guarda para sempre em geo_cache (os bboxes
// legados das 36 cidades da Bahia ja foram semeados como 'config-legacy').
export function createGeocoder(db, { nominatimUrl }) {
const select = db.prepare('SELECT south, west, north, east FROM geo_cache WHERE key = ?');
const insert = db.prepare(
`INSERT OR REPLACE INTO geo_cache (key, south, west, north, east, provider)
VALUES (?, ?, ?, ?, ?, 'nominatim')`
);
return async function resolveBbox(uf, cidade) {
const key = `bbox:${uf}:${cleanText(cidade)}`;
const cached = select.get(key);
if (cached) return [cached.south, cached.west, cached.north, cached.east];
const url = new URL(nominatimUrl);
url.searchParams.set('format', 'jsonv2');
url.searchParams.set('q', `${cidade}, ${UF_NAMES[uf] || uf}, Brasil`);
url.searchParams.set('countrycodes', 'br');
url.searchParams.set('limit', '1');
const { response, data: results } = await fetchJsonWithTimeout(url, { headers: UA_HEADERS });
if (!response.ok) throw new Error(`Nominatim geocode HTTP ${response.status} para ${cidade}/${uf}`);
const box = results[0]?.boundingbox;
if (!box || box.length !== 4) return null;
// Nominatim retorna [sul, norte, oeste, leste]; geo_cache guarda sul/oeste/norte/leste.
const south = Number(box[0]);
const north = Number(box[1]);
const west = Number(box[2]);
const east = Number(box[3]);
if (![south, north, west, east].every(Number.isFinite)) return null;
insert.run(key, south, west, north, east);
return [south, west, north, east];
};
}
+65
View File
@@ -0,0 +1,65 @@
import {
scoreQualidade, scoreComercial, scoreGeral, classificarScoreGeral,
dedupKey, extractDdd, segmentLabel, onlyDigits
} from '../scoring.mjs';
// Converte um item processado ({payload, lead}) — formato produzido por
// processLead e tambem o formato dos JSONs legados — na linha canonica da
// tabela leads, com os scores/classificacao/dedup_key calculados.
export function leadRowFromProcessed(item, fallbackOrigin = '') {
const lead = item.lead || {};
const prospecting = item.payload?.prospecting || lead.raw?.prospecting || {};
const empresa = String(lead.companyName ?? '').trim();
const whatsapp = onlyDigits(lead.phone);
const cnpj = onlyDigits(prospecting.cnpj);
const cidade = String(lead.city ?? '').trim();
const segmento = String(lead.segment ?? '').trim();
const cnae = String(prospecting.cnae ?? '').trim();
const website = String(prospecting.website ?? '').trim();
const email = String(lead.email ?? '').trim();
const affinity = prospecting.affinity || 'media';
const produtos = Array.isArray(prospecting.products) ? prospecting.products : [];
const qualidade = scoreQualidade({ whatsapp, cnpj, empresa, cidade, segmento, cnae, website });
const comercial = scoreComercial({ whatsapp, cnpj, website, email, affinity });
const geral = scoreGeral(qualidade.score, comercial);
const tier = classificarScoreGeral(geral);
const produtoPrincipal = produtos.length
? [...produtos].sort((a, b) => b.estrelas - a.estrelas)[0].nome
: '';
return {
lead_uid: lead.id || null,
dedup_key: dedupKey({ cnpj, whatsapp, empresa, cidade }),
empresa,
legal_name: String(prospecting.legal_name ?? ''),
whatsapp,
ddd: extractDdd(whatsapp),
email,
cidade,
uf: String(lead.state ?? '').trim().toUpperCase(),
segmento,
segmento_label: segmentLabel(segmento),
interesse: String(lead.serviceLabel ?? ''),
cnpj,
cnae,
endereco: String(prospecting.address ?? ''),
website,
pains_json: JSON.stringify(lead.pains || []),
origem: String(lead.source || fallbackOrigin || 'indefinida'),
telefone_valido: qualidade.telefoneValido ? 1 : 0,
cnpj_valido: qualidade.cnpjValido ? 1 : 0,
empresa_valida: qualidade.empresaValida ? 1 : 0,
score: geral,
classificacao: tier.id,
motivo_classificacao: `score geral ${geral} (qualidade ${qualidade.score}, comercial ${comercial})`,
score_qualidade: qualidade.score,
score_comercial: comercial,
prioridade: tier.prioridade,
produto_principal: produtoPrincipal,
produtos_json: JSON.stringify(produtos),
capture_run_id: null,
captured_at: lead.createdAt || null,
exported_at: null
};
}
+133
View File
@@ -0,0 +1,133 @@
import { processLead } from '../lead-engine.mjs';
import { createLeadInserter, transaction } from '../db.mjs';
import { createGeocoder } from './geocode.mjs';
import { leadRowFromProcessed } from './lead-row.mjs';
import { getSource } from '../sources/index.mjs';
import { envString, envNumber, paths } from '../env.mjs';
export function defaultEndpoints() {
return {
overpassUrls: envString(
'OVERPASS_API_URL',
'https://overpass-api.de/api/interpreter,https://overpass.kumi.systems/api/interpreter'
).split(',').map((url) => url.trim()).filter(Boolean),
nominatimUrl: envString('NOMINATIM_API_URL', 'https://nominatim.openstreetmap.org/search'),
wikidataUrl: envString('WIKIDATA_API_URL', 'https://query.wikidata.org/sparql'),
brasilApiUrl: envString('BRASILAPI_URL', 'https://brasilapi.com.br/api').replace(/\/$/, '')
};
}
// Executa uma captura: percorre as fontes selecionadas, processa cada lead
// (normalizacao + score) e insere no banco em lotes. A dedup acontece no
// proprio INSERT (ON CONFLICT dedup_key DO NOTHING) — leads ja capturados
// em qualquer execucao anterior contam como duplicados.
export async function runCapture({ db, runId, params, emit = () => {}, log = () => {}, isCancelled = () => false }) {
const insert = createLeadInserter(db);
const endpoints = defaultEndpoints();
const ctx = {
db,
emit,
log,
isCancelled,
cnpjDir: params.cnpjDir || paths.cnpjDir,
endpoints,
delays: {
overpassDelayMs: envNumber('OVERPASS_REQUEST_DELAY_MS', 5000),
overpassTimeoutMs: envNumber('OVERPASS_TIMEOUT_MS', 15000)
},
overpassTextSearch: envString('OVERPASS_TEXT_SEARCH', 'false') === 'true',
geocode: createGeocoder(db, { nominatimUrl: endpoints.nominatimUrl })
};
const counts = { raw: 0, new: 0, dup: 0 };
const updateRun = runId
? db.prepare(
`UPDATE capture_runs SET raw_count = ?, new_count = ?, dup_count = ?, progress_json = ? WHERE id = ?`
)
: null;
const persistCounts = (progress) => {
if (updateRun) {
updateRun.run(counts.raw, counts.new, counts.dup, JSON.stringify(progress || {}), runId);
}
};
if (runId) {
db.prepare(
`UPDATE capture_runs SET status = 'running', pid = ?, started_at = datetime('now') WHERE id = ?`
).run(process.pid, runId);
}
let cancelled = false;
try {
for (const sourceId of params.sources) {
if (isCancelled()) {
cancelled = true;
break;
}
const source = getSource(sourceId);
if (!source) {
log(`fonte desconhecida: ${sourceId} — pulada`);
continue;
}
log(`fonte ${sourceId}: iniciando`);
emit({ phase: sourceId, message: 'iniciando', counts: { ...counts } });
for await (const batch of source.collect(params, ctx)) {
counts.raw += batch.leads.length;
transaction(db, () => {
for (const payload of batch.leads) {
const result = processLead(payload);
const row = leadRowFromProcessed({ payload, lead: result.lead }, sourceId);
row.capture_run_id = runId || null;
if (insert(row) === 'inserted') counts.new += 1;
else counts.dup += 1;
}
});
const progress = {
phase: sourceId,
message: `${batch.label}: ${counts.new} novos, ${counts.dup} duplicados (${counts.raw} brutos)`,
counts: { ...counts }
};
emit(progress);
persistCounts(progress);
if (isCancelled()) {
cancelled = true;
break;
}
}
if (cancelled) break;
log(`fonte ${sourceId}: concluida`);
}
} catch (error) {
if (runId) {
db.prepare(
`UPDATE capture_runs SET status = 'error', error = ?, raw_count = ?, new_count = ?, dup_count = ?, finished_at = datetime('now') WHERE id = ?`
).run(String(error.message || error), counts.raw, counts.new, counts.dup, runId);
}
throw error;
}
const finalStatus = cancelled ? 'cancelled' : 'done';
if (runId) {
db.prepare(
`UPDATE capture_runs SET status = ?, raw_count = ?, new_count = ?, dup_count = ?, finished_at = datetime('now') WHERE id = ?`
).run(finalStatus, counts.raw, counts.new, counts.dup, runId);
}
return { ...counts, status: finalStatus };
}
export function createCaptureRun(db, params) {
const result = db.prepare('INSERT INTO capture_runs (params_json, status) VALUES (?, ?)')
.run(JSON.stringify(params), 'pending');
return Number(result.lastInsertRowid);
}
+1168
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Raiz do projeto resolvida a partir deste arquivo (src/env.mjs -> pasta acima),
// para funcionar igual quando o servidor e iniciado pelo launcher (cwd diferente).
export const projectRoot = path.dirname(fileURLToPath(new URL('.', import.meta.url)));
export const paths = {
root: projectRoot,
data: path.join(projectRoot, 'data'),
db: process.env.APP_DB_PATH || path.join(projectRoot, 'data', 'maquina-pessoal.db'),
whatsappAuth: process.env.APP_WHATSAPP_AUTH_DIR || path.join(projectRoot, 'data', 'runtime', 'whatsapp-auth'),
exports: path.join(projectRoot, 'data', 'exports'),
seeds: path.join(projectRoot, 'data', 'seeds'),
cnpjDir: process.env.CNPJ_BASE_DIR || path.join(projectRoot, 'prospeccao', 'cnpj'),
rfbRaw: path.join(projectRoot, 'prospeccao', 'cnpj', 'rfb-raw'),
config: path.join(projectRoot, 'prospeccao', 'config.json'),
public: path.join(projectRoot, 'public')
};
export let APP_PORT = envNumber('APP_PORT', 3910);
function refreshEnvDerivedValues() {
paths.db = process.env.APP_DB_PATH || path.join(projectRoot, 'data', 'maquina-pessoal.db');
paths.whatsappAuth = process.env.APP_WHATSAPP_AUTH_DIR || path.join(projectRoot, 'data', 'runtime', 'whatsapp-auth');
paths.cnpjDir = process.env.CNPJ_BASE_DIR || path.join(projectRoot, 'prospeccao', 'cnpj');
paths.rfbRaw = path.join(paths.cnpjDir, 'rfb-raw');
APP_PORT = envNumber('APP_PORT', 3910);
}
export async function loadDotEnv(rootDir = projectRoot) {
try {
const content = await fs.readFile(path.join(rootDir, '.env'), 'utf8');
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const separator = trimmed.indexOf('=');
if (separator === -1) continue;
const key = trimmed.slice(0, separator).trim();
const value = trimmed.slice(separator + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
refreshEnvDerivedValues();
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
export function envNumber(name, fallback) {
const value = Number(process.env[name]);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
export function envString(name, fallback) {
return process.env[name] || fallback;
}
+107
View File
@@ -0,0 +1,107 @@
import fs from 'node:fs';
import path from 'node:path';
import { csvEscape, excelTextCell, neutralizeSpreadsheetFormula, BOM } from '../util/csv.mjs';
import { iterateLeads } from '../treatment/queries.mjs';
// Exportacao "planilha": CSV + .xls gemeo, ambos a prova de Excel — os campos
// numericos longos (whatsapp, cnpj, cnae) sao gravados como TEXTO para nunca
// virarem notacao cientifica. Mesmo formato dos arquivos TRATADOS legados:
// CSV -> formula ="valor"
// XLS -> tabela HTML com mso-number-format:"\@"
const HEADERS = [
'empresa', 'whatsapp', 'ddd', 'cidade', 'uf', 'segmento', 'segmento_legivel', 'interesse',
'cnpj', 'cnae', 'telefone_valido', 'motivo_telefone', 'cnpj_valido', 'empresa_valida',
'motivo_empresa', 'score', 'classificacao', 'motivo_classificacao', 'chave_duplicidade', 'linha_origem',
'score_qualidade', 'score_comercial', 'prioridade', 'produto_recomendado'
];
const TEXT_COLUMNS = new Set(['whatsapp', 'ddd', 'cnpj', 'cnae']);
function leadToExportRow(lead) {
return {
empresa: lead.empresa,
whatsapp: lead.whatsapp,
ddd: lead.ddd,
cidade: lead.cidade,
uf: lead.uf,
segmento: lead.segmento,
segmento_legivel: lead.segmento_label,
interesse: lead.interesse,
cnpj: lead.cnpj,
cnae: lead.cnae,
telefone_valido: lead.telefone_valido ? 'sim' : 'nao',
motivo_telefone: lead.telefone_valido ? 'telefone valido' : 'telefone invalido ou vazio',
cnpj_valido: lead.cnpj_valido ? 'sim' : 'nao',
empresa_valida: lead.empresa_valida ? 'sim' : 'nao',
motivo_empresa: lead.empresa_valida ? 'empresa valida' : (lead.motivo_classificacao || 'empresa invalida'),
score: lead.score,
classificacao: lead.classificacao,
motivo_classificacao: lead.motivo_classificacao,
chave_duplicidade: lead.dedup_key,
linha_origem: lead.id,
score_qualidade: lead.score_qualidade,
score_comercial: lead.score_comercial,
prioridade: lead.prioridade,
produto_recomendado: lead.produto_principal
};
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
async function writeStream(stream, text) {
if (!stream.write(text)) {
await new Promise((resolve) => stream.once('drain', resolve));
}
}
export async function exportExcel({ db, filtro, dir, onProgress = () => {}, collectIds }) {
fs.mkdirSync(dir, { recursive: true });
const baseName = 'leads-excel';
const csvPath = path.join(dir, `${baseName}.csv`);
const xlsPath = path.join(dir, `${baseName}.xls`);
const csv = fs.createWriteStream(csvPath, { encoding: 'utf8' });
const xls = fs.createWriteStream(xlsPath, { encoding: 'utf8' });
await writeStream(csv, BOM + HEADERS.join(';') + '\n');
await writeStream(xls, `${BOM}<html><head><meta charset="utf-8"><style>td.text{mso-number-format:"\\@";}</style></head><body><table border="1"><tr>${
HEADERS.map((h) => `<th>${escapeHtml(h)}</th>`).join('')
}</tr>\n`);
let total = 0;
for (const lead of iterateLeads(db, filtro)) {
const row = leadToExportRow(lead);
const csvCells = HEADERS.map((h) => (TEXT_COLUMNS.has(h) && row[h] !== ''
? excelTextCell(row[h])
: csvEscape(neutralizeSpreadsheetFormula(row[h]))));
await writeStream(csv, csvCells.join(';') + '\n');
const xlsCells = HEADERS.map((h) =>
`<td${TEXT_COLUMNS.has(h) ? ' class="text"' : ''}>${escapeHtml(TEXT_COLUMNS.has(h) ? row[h] : neutralizeSpreadsheetFormula(row[h]))}</td>`
);
await writeStream(xls, `<tr>${xlsCells.join('')}</tr>\n`);
if (collectIds) collectIds.push(lead.id);
total += 1;
if (total % 10000 === 0) onProgress({ message: `${total} leads gravados...` });
}
await writeStream(xls, '</table></body></html>\n');
await Promise.all([
new Promise((resolve, reject) => csv.end((e) => (e ? reject(e) : resolve()))),
new Promise((resolve, reject) => xls.end((e) => (e ? reject(e) : resolve())))
]);
return {
total,
files: [path.basename(csvPath), path.basename(xlsPath)]
};
}
+87
View File
@@ -0,0 +1,87 @@
import path from 'node:path';
import { loadDotEnv, paths } from '../env.mjs';
import { openDb, transaction } from '../db.mjs';
import { createJobIO } from '../jobs/protocol.mjs';
import { exportExcel } from './excel.mjs';
import { randomUUID } from 'node:crypto';
import { createJobLease } from '../jobs/lease.mjs';
// Processo filho de exportacao: gera os arquivos do tipo pedido e, ao final,
// numa unica transacao, registra os leads exportados (export_leads) e marca
// exported_at (para o filtro "ainda nao exportado" excluir esses leads).
await loadDotEnv();
const args = new Map(
process.argv.slice(2).map((arg) => {
const [key, value = 'true'] = arg.replace(/^--/, '').split('=');
return [key, value];
})
);
const io = createJobIO();
const GENERATORS = {
excel: exportExcel
};
async function main() {
const exportId = Number(args.get('export-id'));
if (!exportId) throw new Error('export-job: --export-id obrigatorio');
const db = openDb();
let lease;
try {
const row = db.prepare('SELECT * FROM exports WHERE id = ?').get(exportId);
if (!row) throw new Error(`export #${exportId} nao encontrado`);
const generator = GENERATORS[row.tipo];
if (!generator) throw new Error(`tipo de exportacao desconhecido: ${row.tipo}`);
lease = createJobLease({ db, table: 'exports', id: exportId, ownerToken: args.get('owner-token') || randomUUID() });
const filtro = JSON.parse(row.filtro_json || '{}');
const params = JSON.parse(row.params_json || '{}');
io.emit({ phase: row.tipo, message: 'gerando arquivos...' });
const dir = path.join(paths.exports, `export-${exportId}`);
const leadIds = [];
const result = await generator({
db,
filtro,
params,
dir,
collectIds: leadIds,
onProgress: (p) => io.emit({ phase: row.tipo, ...p })
});
const marcar = params.marcarExportado !== false;
io.emit({ phase: row.tipo, message: `registrando ${leadIds.length} leads exportados...` });
transaction(db, () => {
const insertLink = db.prepare('INSERT OR IGNORE INTO export_leads (export_id, lead_id) VALUES (?, ?)');
const mark = marcar
? db.prepare("UPDATE leads SET exported_at = COALESCE(exported_at, datetime('now')), updated_at = datetime('now') WHERE id = ?")
: null;
for (const leadId of leadIds) {
insertLink.run(exportId, leadId);
if (mark) mark.run(leadId);
}
db.prepare(
"UPDATE exports SET status = 'done', total_leads = ?, arquivos_json = ?, finished_at = datetime('now') WHERE id = ?"
).run(result.total, JSON.stringify(result.files), exportId);
});
io.done({ total: result.total, arquivos: result.files });
} catch (error) {
db.prepare("UPDATE exports SET status = 'error', error = ?, finished_at = datetime('now') WHERE id = ? AND status IN ('pending','running')")
.run(String(error.message || error), exportId);
throw error;
} finally {
lease?.close();
db.close();
}
}
main().catch((error) => {
io.fail(error);
process.exit(1);
});
+100
View File
@@ -0,0 +1,100 @@
import path from 'node:path';
import { loadDotEnv, paths } from '../env.mjs';
import { openDb } from '../db.mjs';
import { extractUf } from './rfb-extractor.mjs';
import { downloadRfbDataset, validateRfbRawFiles } from './rfb-downloader.mjs';
import { createJobIO } from '../jobs/protocol.mjs';
import { randomUUID } from 'node:crypto';
import { createJobLease } from '../jobs/lease.mjs';
// Processo filho de extracao RFB: le a UF da linha em extractions e roda a
// extracao completa, reportando progresso via stdout.
await loadDotEnv();
const args = new Map(
process.argv.slice(2).map((arg) => {
const [key, value = 'true'] = arg.replace(/^--/, '').split('=');
return [key, value];
})
);
const io = createJobIO();
async function main() {
const extractionId = Number(args.get('extraction-id'));
if (!extractionId) throw new Error('extract-job: --extraction-id obrigatorio');
const db = openDb();
let lease;
try {
const extraction = db.prepare('SELECT * FROM extractions WHERE id = ?').get(extractionId);
if (!extraction) throw new Error(`extraction #${extractionId} nao encontrada`);
const uf = extraction.uf;
lease = createJobLease({ db, table: 'extractions', id: extractionId, ownerToken: args.get('owner-token') || randomUUID() });
const outCsv = path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`);
const checkpointPath = path.join(paths.cnpjDir, `checkpoint-estabelecimentos-${uf}.ndjson`);
const download = await downloadRfbDataset({
rawDir: paths.rfbRaw,
isCancelled: io.isCancelled,
onProgress: (progress) => {
const mapped = { ...progress, pct: Math.round((progress.pct || 0) * 0.45) };
io.emit(mapped);
db.prepare('UPDATE extractions SET progress_json = ? WHERE id = ?')
.run(JSON.stringify(mapped), extractionId);
}
});
if (download.cancelled) {
db.prepare("UPDATE extractions SET status = 'cancelled', finished_at = datetime('now') WHERE id = ?").run(extractionId);
io.done({ cancelled: true, snapshot: download.snapshot, downloaded: download.downloaded.length });
return;
}
await validateRfbRawFiles(paths.rfbRaw);
const result = await extractUf({
uf,
rawDir: paths.rfbRaw,
outCsv,
checkpointPath,
db,
log: (m) => io.log(m),
isCancelled: io.isCancelled,
onProgress: (p) => {
const mapped = { ...p, pct: 45 + Math.round((p.pct || 0) * 0.55) };
io.emit(mapped);
db.prepare('UPDATE extractions SET progress_json = ? WHERE id = ?')
.run(JSON.stringify(mapped), extractionId);
}
});
if (result.cancelled) {
db.prepare("UPDATE extractions SET status = 'cancelled', finished_at = datetime('now') WHERE id = ?")
.run(extractionId);
io.done({ cancelled: true });
} else {
db.prepare(
`UPDATE extractions SET status = 'done', csv_path = ?, total_rows = ?, with_phone = ?,
municipios_count = ?, finished_at = datetime('now') WHERE id = ?`
).run(result.csvPath, result.totalRows, result.withPhone, result.municipiosCount, extractionId);
io.done({
snapshot: download.snapshot,
downloaded: download.downloaded.length,
skipped: download.skipped.length,
totalRows: result.totalRows,
withPhone: result.withPhone,
municipios: result.municipiosCount
});
}
} catch (error) {
db.prepare("UPDATE extractions SET status = 'error', error = ?, finished_at = datetime('now') WHERE id = ? AND status IN ('pending','running')")
.run(String(error.message || error), extractionId);
throw error;
} finally {
lease?.close();
db.close();
}
}
main().catch((error) => {
io.fail(error);
process.exit(1);
});
+576
View File
@@ -0,0 +1,576 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { fetchTextWithTimeout } from '../util/fetch.mjs';
export const REQUIRED_RFB_FILES = [
'Municipios.zip',
...Array.from({ length: 10 }, (_, index) => `Empresas${index}.zip`),
...Array.from({ length: 10 }, (_, index) => `Estabelecimentos${index}.zip`)
];
export const DEFAULT_RFB_INDEX_URL = 'https://dados-abertos-rf-cnpj.casadosdados.com.br/arquivos/';
const DEFAULT_MIN_ZIP_BYTES = 1024;
const MANIFEST_FILE = '.rfb-snapshot.json';
const BACKUP_DIR = '.rfb-backup';
const INCOMPLETE_DIR = '.rfb-incomplete';
const EXTRACTION_MARGIN_BYTES = 2 * 1024 * 1024 * 1024;
// Covers the compressed download, extraction checkpoints and generated CSV.
export const DEFAULT_MINIMUM_FREE_BYTES = 8 * 1024 * 1024 * 1024;
class CancelledError extends Error {}
export function parseLatestSnapshot(html) {
const dates = [...String(html).matchAll(/href\s*=\s*["'](\d{4}-\d{2}-\d{2})\/["']/gi)]
.map((match) => match[1])
.filter((date) => !Number.isNaN(Date.parse(`${date}T00:00:00Z`)));
if (!dates.length) throw new Error('nenhum snapshot YYYY-MM-DD encontrado no indice da RFB');
return dates.sort().at(-1);
}
async function fetchResponse(fetchImpl, url, options = {}, timeoutMs = 25_000) {
const controller = new AbortController();
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
const timer = setTimeout(() => controller.abort(new Error(`timeout apos ${timeoutMs}ms`)), timeoutMs);
timer.unref?.();
try {
return await fetchImpl(url, { ...options, signal });
} finally {
clearTimeout(timer);
}
}
export async function discoverLatestSnapshot(indexUrl = DEFAULT_RFB_INDEX_URL, { fetch: fetchImpl = globalThis.fetch } = {}) {
const normalizedUrl = indexUrl.endsWith('/') ? indexUrl : `${indexUrl}/`;
const { response, text } = await fetchTextWithTimeout(normalizedUrl, { fetch: fetchImpl }, 25_000, { maxBytes: 2_000_000 });
if (!response.ok) throw new Error(`indice da RFB respondeu HTTP ${response.status}`);
return parseLatestSnapshot(text);
}
async function inspectZip(file, minZipBytes, expectedSize) {
const stat = await fsp.stat(file);
if (stat.size < minZipBytes) throw new Error(`${path.basename(file)} invalido: tamanho ${stat.size}, minimo ${minZipBytes}`);
if (expectedSize != null && stat.size !== expectedSize) {
throw new Error(`${path.basename(file)} incompleto: ${stat.size} bytes, esperado ${expectedSize}`);
}
const handle = await fsp.open(file, 'r');
try {
const signature = Buffer.alloc(2);
await handle.read(signature, 0, 2, 0);
if (signature.toString('ascii') !== 'PK') throw new Error(`${path.basename(file)} invalido: assinatura ZIP PK ausente`);
const tailSize = Math.min(stat.size, 65_557);
const tail = Buffer.alloc(tailSize);
await handle.read(tail, 0, tailSize, stat.size - tailSize);
let eocdOffset = -1;
for (let offset = tail.length - 22; offset >= 0; offset -= 1) {
if (tail.readUInt32LE(offset) !== 0x06054b50) continue;
const commentLength = tail.readUInt16LE(offset + 20);
if (offset + 22 + commentLength === tail.length) {
eocdOffset = offset;
break;
}
}
if (eocdOffset < 0) throw new Error(`${path.basename(file)} invalido: registro EOCD ausente ou truncado`);
const disk = tail.readUInt16LE(eocdOffset + 4);
const centralDisk = tail.readUInt16LE(eocdOffset + 6);
const entriesOnDisk = tail.readUInt16LE(eocdOffset + 8);
const entriesTotal = tail.readUInt16LE(eocdOffset + 10);
const centralSize = tail.readUInt32LE(eocdOffset + 12);
const centralOffset = tail.readUInt32LE(eocdOffset + 16);
if (disk === 0xffff || centralDisk === 0xffff || entriesOnDisk === 0xffff || entriesTotal === 0xffff
|| centralSize === 0xffffffff || centralOffset === 0xffffffff) {
throw new Error(`${path.basename(file)} invalido: ZIP64 nao suportado`);
}
if (disk !== 0 || centralDisk !== 0) throw new Error(`${path.basename(file)} invalido: ZIP multi-disco nao suportado`);
if (entriesOnDisk !== entriesTotal) throw new Error(`${path.basename(file)} invalido: contagem do diretorio central divergente`);
const absoluteEocd = stat.size - tailSize + eocdOffset;
if (centralOffset + centralSize > absoluteEocd) {
throw new Error(`${path.basename(file)} invalido: diretorio central ultrapassa o EOCD`);
}
if (entriesTotal > 0) {
const centralSignature = Buffer.alloc(4);
const { bytesRead } = await handle.read(centralSignature, 0, 4, centralOffset);
if (bytesRead !== 4 || centralSignature.readUInt32LE(0) !== 0x02014b50) {
throw new Error(`${path.basename(file)} invalido: assinatura do diretorio central ausente`);
}
}
} finally {
await handle.close();
}
return stat.size;
}
export async function validateRfbRawFiles(rawDir, { minZipBytes = DEFAULT_MIN_ZIP_BYTES, sizes = {} } = {}) {
const problems = [];
const files = [];
for (const name of REQUIRED_RFB_FILES) {
try {
const expected = typeof sizes[name] === 'number' ? sizes[name] : sizes[name]?.size;
const size = await inspectZip(path.join(rawDir, name), minZipBytes, expected);
files.push({ name, size });
} catch (error) {
problems.push(`${name}: ${error.code === 'ENOENT' ? 'ausente' : error.message}`);
}
}
if (problems.length) {
throw new Error(`base RFB invalida; exige os 21 arquivos ZIP: ${problems.join('; ')}`);
}
return files;
}
async function readManifest(rawDir) {
try {
return JSON.parse(await fsp.readFile(path.join(rawDir, MANIFEST_FILE), 'utf8'));
} catch {
return null;
}
}
async function writeManifest(rawDir, manifest) {
const manifestPath = path.join(rawDir, MANIFEST_FILE);
const tempPath = `${manifestPath}.tmp`;
await fsp.writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
await fsp.rename(tempPath, manifestPath);
}
function totalFromResponse(response, offset) {
const range = response.headers.get('content-range');
if (range) {
const match = /\/([0-9]+)$/.exec(range);
if (match) return Number(match[1]);
}
const length = Number(response.headers.get('content-length'));
return Number.isFinite(length) && length >= 0 ? offset + length : null;
}
function responseValidator(response) {
const etag = response.headers.get('etag');
const lastModified = response.headers.get('last-modified');
return { validator: etag || lastModified || null, etag, lastModified };
}
function numericContentLength(response) {
const raw = response.headers.get('content-length');
if (!/^\d+$/.test(raw || '')) return null;
const value = Number(raw);
return Number.isSafeInteger(value) && value >= 0 ? value : null;
}
async function downloadOne({ rawDir, snapshotUrl, name, fetchImpl, minZipBytes, isCancelled, onProgress, idleTimeoutMs, manifest }) {
const finalPath = path.join(rawDir, name);
const partPath = `${finalPath}.part`;
let offset = 0;
try {
offset = (await fsp.stat(partPath)).size;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
manifest.partials ||= {};
const saved = manifest.partials[name];
const mayResume = offset > 0 && saved?.validator && Number.isSafeInteger(saved?.totalSize)
&& saved.totalSize > offset;
if (!mayResume) offset = 0;
let response;
let expectedSize;
let metadata;
for (let attempt = 0; attempt < 2; attempt += 1) {
const headers = offset ? { Range: `bytes=${offset}-`, 'If-Range': saved.validator } : {};
response = await fetchResponse(fetchImpl, new URL(name, snapshotUrl), { headers });
if (!response.ok) throw new Error(`${name}: HTTP ${response.status}`);
if (!response.body) throw new Error(`${name}: resposta sem corpo`);
const current = responseValidator(response);
let invalid = false;
let reason = '';
if (offset) {
const range = response.headers.get('content-range');
const match = /^bytes (\d+)-(\d+)\/(\d+)$/i.exec(range || '');
const length = numericContentLength(response);
expectedSize = match ? Number(match[3]) : null;
invalid = response.status !== 206
|| !match
|| Number(match[1]) !== offset
|| Number(match[2]) < Number(match[1])
|| expectedSize !== saved.totalSize
|| length !== Number(match[2]) - Number(match[1]) + 1
|| current.validator !== saved.validator;
reason = `Content-Range/validator invalido para retomada: ${range || 'ausente'}`;
} else {
const length = numericContentLength(response);
invalid = response.status !== 200 || length === null;
expectedSize = length;
reason = response.status === 206
? `${name}: Content-Range inesperado sem retomada`
: `${name}: resposta final sem Content-Length numerico confiavel`;
}
if (!invalid) {
metadata = { ...current, totalSize: expectedSize };
break;
}
await response.body.cancel?.();
if (!offset || attempt > 0) throw new Error(reason);
await fsp.rm(partPath, { force: true });
delete manifest.partials[name];
await writeManifest(rawDir, manifest);
offset = 0;
}
manifest.partials[name] = metadata;
await writeManifest(rawDir, manifest);
let received = offset;
let idleTimer;
let lastProgressAt = 0;
const emitProgress = (force = false) => {
const now = Date.now();
if (!force && now - lastProgressAt < 500) return;
lastProgressAt = now;
onProgress({ phase: 'download', file: name, bytes: received, totalBytes: expectedSize, message: `baixando ${name}` });
};
emitProgress(true);
const guard = new Transform({
transform(chunk, encoding, callback) {
if (isCancelled()) return callback(new CancelledError('download cancelado'));
clearTimeout(idleTimer);
idleTimer = setTimeout(
() => guard.destroy(new Error(`${name}: download sem dados por ${idleTimeoutMs}ms`)),
idleTimeoutMs
);
idleTimer.unref?.();
received += chunk.length;
emitProgress();
callback(null, chunk);
}
});
try {
idleTimer = setTimeout(
() => guard.destroy(new Error(`${name}: download sem dados por ${idleTimeoutMs}ms`)),
idleTimeoutMs
);
idleTimer.unref?.();
await pipeline(Readable.fromWeb(response.body), guard, fs.createWriteStream(partPath, { flags: offset ? 'a' : 'w' }));
} catch (error) {
if (error instanceof CancelledError || isCancelled()) throw new CancelledError('download cancelado');
throw error;
} finally {
clearTimeout(idleTimer);
}
await inspectZip(partPath, minZipBytes, expectedSize);
emitProgress(true);
await fsp.rename(partPath, finalPath);
delete manifest.partials[name];
return { name, size: received, etag: metadata.etag, lastModified: metadata.lastModified };
}
async function ensureFreeSpace(rawDir, statfs, minimumFreeBytes) {
const stats = await statfs(rawDir);
const free = BigInt(stats.bavail) * BigInt(stats.bsize);
if (free >= BigInt(minimumFreeBytes)) return;
const error = new Error(`espaco livre insuficiente: exige ${minimumFreeBytes} bytes`);
error.code = 'ENOSPC';
throw error;
}
async function readValidDataset(dir, minZipBytes) {
const manifest = await readManifest(dir);
if (!manifest?.complete) return null;
const hasSizes = REQUIRED_RFB_FILES.every((name) => {
const metadata = manifest.files?.[name];
const size = typeof metadata === 'number' ? metadata : metadata?.size;
return Number.isSafeInteger(size) && size >= minZipBytes;
});
if (!hasSizes) return null;
try {
await validateRfbRawFiles(dir, { minZipBytes, sizes: manifest.files });
return manifest;
} catch {
return null;
}
}
async function archiveIncompleteDataset(rawDir, { includeParts = false } = {}) {
let archivePath = path.join(rawDir, INCOMPLETE_DIR);
try {
await fsp.access(archivePath);
archivePath = path.join(rawDir, `${INCOMPLETE_DIR}-${Date.now()}`);
} catch {}
await fsp.mkdir(archivePath);
let moved = 0;
for (const name of [...REQUIRED_RFB_FILES, MANIFEST_FILE]) {
for (const suffix of ['', ...(includeParts && name !== MANIFEST_FILE ? ['.part'] : [])]) {
try {
await fsp.rename(path.join(rawDir, `${name}${suffix}`), path.join(archivePath, `${name}${suffix}`));
moved += 1;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
}
if (!moved) await fsp.rm(archivePath, { recursive: true, force: true });
return moved ? archivePath : null;
}
async function backupPreviousDataset(rawDir, minZipBytes) {
const backupPath = path.join(rawDir, BACKUP_DIR);
if (await readValidDataset(backupPath, minZipBytes)) return backupPath;
await fsp.rm(backupPath, { recursive: true, force: true });
if (!await readValidDataset(rawDir, minZipBytes)) return null;
const staging = `${backupPath}.tmp`;
await fsp.rm(staging, { recursive: true, force: true });
await fsp.mkdir(staging);
const moved = [];
try {
for (const name of [...REQUIRED_RFB_FILES, MANIFEST_FILE]) {
const source = path.join(rawDir, name);
try {
await fsp.rename(source, path.join(staging, name));
moved.push(name);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
await fsp.rename(staging, backupPath);
return backupPath;
} catch (error) {
for (const name of moved.reverse()) {
await fsp.rename(path.join(staging, name), path.join(rawDir, name)).catch(() => {});
}
await fsp.rm(staging, { recursive: true, force: true });
throw error;
}
}
async function restoreBackup(rawDir, minZipBytes) {
const backupPath = path.join(rawDir, BACKUP_DIR);
const manifest = await readValidDataset(backupPath, minZipBytes);
if (!manifest) return null;
await archiveIncompleteDataset(rawDir);
const staging = path.join(rawDir, `${BACKUP_DIR}-restore.tmp`);
await fsp.rm(staging, { recursive: true, force: true });
await fsp.mkdir(staging);
try {
for (const name of [...REQUIRED_RFB_FILES, MANIFEST_FILE]) {
await fsp.copyFile(path.join(backupPath, name), path.join(staging, name));
}
for (const name of REQUIRED_RFB_FILES) {
await fsp.rename(path.join(staging, name), path.join(rawDir, name));
}
await fsp.rename(path.join(staging, MANIFEST_FILE), path.join(rawDir, MANIFEST_FILE));
} finally {
await fsp.rm(staging, { recursive: true, force: true });
}
return manifest;
}
async function revalidateCachedFiles({ previous, snapshotUrl, fetchImpl }) {
for (const name of REQUIRED_RFB_FILES) {
const metadata = previous.files[name];
if (!metadata || typeof metadata !== 'object') continue;
const validator = metadata.etag || metadata.lastModified;
if (!validator) continue;
const headers = metadata.etag
? { 'If-None-Match': metadata.etag }
: { 'If-Modified-Since': metadata.lastModified };
const response = await fetchResponse(fetchImpl, new URL(name, snapshotUrl), { method: 'HEAD', headers });
if (response.status === 304) continue;
if (!response.ok) throw new Error(`${name}: HEAD respondeu HTTP ${response.status}`);
const length = numericContentLength(response);
const current = responseValidator(response);
const changed = length !== metadata.size
|| (metadata.etag && current.etag !== metadata.etag)
|| (metadata.lastModified && current.lastModified !== metadata.lastModified);
if (changed) return name;
}
return null;
}
async function usefulPresentBytes(rawDir, previous, sameSnapshot, minZipBytes) {
if (!sameSnapshot || !previous || previous.complete) return 0;
let useful = 0;
for (const name of REQUIRED_RFB_FILES) {
const metadata = previous.files?.[name];
const size = typeof metadata === 'number' ? metadata : metadata?.size;
if (Number.isSafeInteger(size) && size >= minZipBytes) {
try {
await inspectZip(path.join(rawDir, name), minZipBytes, size);
useful += size;
} catch {}
}
const partial = previous.partials?.[name];
if (!partial?.validator || !Number.isSafeInteger(partial.totalSize)) continue;
try {
const partSize = (await fsp.stat(path.join(rawDir, `${name}.part`))).size;
if (partSize > 0 && partSize < partial.totalSize) useful += partSize;
} catch {}
}
return useful;
}
export async function downloadRfbDataset({
rawDir,
indexUrl = DEFAULT_RFB_INDEX_URL,
fetch: fetchImpl = globalThis.fetch,
onProgress = () => {},
isCancelled = () => false,
concurrency = 1,
minZipBytes = DEFAULT_MIN_ZIP_BYTES,
idleTimeoutMs = 60_000,
statfs = fsp.statfs,
minimumFreeBytes = DEFAULT_MINIMUM_FREE_BYTES
}) {
if (!rawDir) throw new Error('rawDir obrigatorio');
if (concurrency < 1 || concurrency > 2) throw new Error('concurrency deve ser 1 ou 2');
await fsp.mkdir(rawDir, { recursive: true });
const previous = await readManifest(rawDir);
let cachedFiles = null;
const hasCompleteSizes = previous?.complete && REQUIRED_RFB_FILES.every((name) => {
const metadata = previous.files?.[name];
const size = typeof metadata === 'number' ? metadata : metadata?.size;
return Number.isSafeInteger(size) && size >= minZipBytes;
});
if (hasCompleteSizes) {
try {
cachedFiles = await validateRfbRawFiles(rawDir, { minZipBytes, sizes: previous.files });
} catch {
cachedFiles = null;
}
}
onProgress({ phase: 'download', pct: 0, message: 'verificando snapshot da RFB' });
let snapshot;
try {
snapshot = await discoverLatestSnapshot(indexUrl, { fetch: fetchImpl });
} catch (error) {
if (!cachedFiles) {
const restored = await restoreBackup(rawDir, minZipBytes);
if (!restored) throw error;
onProgress({ phase: 'download', pct: 100, message: `sem acesso ao indice; backup ${restored.snapshot} restaurado e validado` });
return {
cancelled: false,
offline: true,
snapshot: restored.snapshot,
downloaded: [],
skipped: REQUIRED_RFB_FILES.slice(),
files: restored.files
};
}
onProgress({ phase: 'download', pct: 100, message: `sem acesso ao indice; usando snapshot local validado ${previous.snapshot}` });
return {
cancelled: false,
offline: true,
snapshot: previous.snapshot,
downloaded: [],
skipped: REQUIRED_RFB_FILES.slice(),
files: previous.files
};
}
const normalizedIndexUrl = indexUrl.endsWith('/') ? indexUrl : `${indexUrl}/`;
const snapshotUrl = new URL(`${snapshot}/`, normalizedIndexUrl);
const sameSnapshot = previous?.snapshot === snapshot;
let forceRefresh = false;
if (sameSnapshot && cachedFiles) {
try {
forceRefresh = Boolean(await revalidateCachedFiles({ previous, snapshotUrl, fetchImpl }));
} catch {
onProgress({ phase: 'download', pct: 100, message: `revalidacao remota indisponivel; usando snapshot local validado ${snapshot}` });
return {
cancelled: false,
offline: true,
snapshot,
downloaded: [],
skipped: REQUIRED_RFB_FILES.slice(),
files: previous.files
};
}
}
if (sameSnapshot && cachedFiles && !forceRefresh) {
onProgress({ phase: 'download', pct: 100, message: `snapshot ${snapshot} ja esta completo e validado` });
return {
cancelled: false,
offline: false,
snapshot,
downloaded: [],
skipped: REQUIRED_RFB_FILES.slice(),
files: previous.files
};
}
const usefulBytes = await usefulPresentBytes(rawDir, previous, sameSnapshot && !forceRefresh, minZipBytes);
const requiredFree = sameSnapshot && !forceRefresh
? Math.max(EXTRACTION_MARGIN_BYTES, minimumFreeBytes - usefulBytes)
: minimumFreeBytes;
await ensureFreeSpace(rawDir, statfs, requiredFree);
onProgress({ phase: 'download', pct: 0, message: `verificando espaco necessario (${requiredFree} bytes)` });
const changed = !sameSnapshot || forceRefresh;
if (changed) {
await backupPreviousDataset(rawDir, minZipBytes);
if (previous && !previous.complete) await archiveIncompleteDataset(rawDir, { includeParts: true });
for (const name of REQUIRED_RFB_FILES) {
await fsp.rm(path.join(rawDir, name), { force: true });
await fsp.rm(path.join(rawDir, `${name}.part`), { force: true });
}
}
const manifest = {
snapshot,
complete: false,
files: sameSnapshot && !forceRefresh && previous?.files && typeof previous.files === 'object' ? { ...previous.files } : {},
partials: sameSnapshot && !forceRefresh && previous?.partials && typeof previous.partials === 'object' ? { ...previous.partials } : {}
};
await writeManifest(rawDir, manifest);
const downloaded = [];
const skipped = [];
try {
for (let index = 0; index < REQUIRED_RFB_FILES.length; index += 1) {
if (isCancelled()) throw new CancelledError('download cancelado');
const name = REQUIRED_RFB_FILES[index];
try {
if (!sameSnapshot || forceRefresh || !manifest.files[name]) throw new Error('arquivo sem origem de snapshot confirmada');
const expected = typeof manifest.files[name] === 'number' ? manifest.files[name] : manifest.files[name]?.size;
if (!Number.isSafeInteger(expected) || expected < 0) throw new Error('arquivo sem tamanho esperado no manifesto');
await inspectZip(path.join(rawDir, name), minZipBytes, expected);
skipped.push(name);
} catch {
const file = await downloadOne({
rawDir,
snapshotUrl,
name,
fetchImpl,
minZipBytes,
isCancelled,
onProgress,
idleTimeoutMs,
manifest
});
downloaded.push(file);
manifest.files[name] = { size: file.size, etag: file.etag, lastModified: file.lastModified };
await writeManifest(rawDir, manifest);
}
onProgress({
phase: 'download',
pct: Math.round(((index + 1) / REQUIRED_RFB_FILES.length) * 100),
message: `${snapshot}: ${index + 1}/${REQUIRED_RFB_FILES.length} arquivos prontos`
});
}
} catch (error) {
if (error instanceof CancelledError || isCancelled()) return { cancelled: true, snapshot, downloaded, skipped };
throw error;
}
const files = await validateRfbRawFiles(rawDir, { minZipBytes });
const finalFiles = Object.fromEntries(files.map((file) => {
const metadata = manifest.files[file.name];
return [file.name, typeof metadata === 'object' ? { ...metadata, size: file.size } : { size: file.size, etag: null, lastModified: null }];
}));
await writeManifest(rawDir, { snapshot, complete: true, files: finalFiles, partials: {} });
await fsp.rm(path.join(rawDir, BACKUP_DIR), { recursive: true, force: true });
const checkpointDir = path.dirname(rawDir);
for (const name of await fsp.readdir(checkpointDir)) {
if (/^checkpoint-estabelecimentos-.*\.ndjson(?:\.tmp)?$/i.test(name)) {
await fsp.rm(path.join(checkpointDir, name), { force: true });
}
}
return { cancelled: false, snapshot, downloaded: downloaded.map((file) => file.name), skipped, files: finalFiles };
}
+423
View File
@@ -0,0 +1,423 @@
import { spawn, spawnSync } from 'node:child_process';
import readline from 'node:readline';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { cleanText, stripAccents } from '../lead-engine.mjs';
import { copyStr } from '../util/collections.mjs';
import { csvEscape, BOM } from '../util/csv.mjs';
import { validateRfbRawFiles } from './rfb-downloader.mjs';
// Extrai a base de estabelecimentos ativos de UMA UF inteira (todos os
// municipios) a partir dos zips brutos da Receita Federal, gerando
// prospeccao/cnpj/estabelecimentos-<UF>.csv.
//
// Cuidados de memoria (aprendidos nas execucoes da Bahia):
// - checkpoint em NDJSON streamado para disco: nunca guarda o array de
// matches em memoria (uma UF inteira passa de 1 milhao de linhas);
// - copyStr (Buffer.from) quebra o vinculo SlicedString do V8 entre campos
// retidos e a linha CSV original;
// - firstField evita o split completo das ~50 milhoes de linhas descartadas
// dos Empresas*.zip.
const CANDIDATE_READERS = [
{ name: 'unzip', probeArgs: ['-v'], argsFor: (zipPath) => ['-p', zipPath] },
{ name: 'C:\\Program Files\\Git\\usr\\bin\\unzip.exe', probeArgs: ['-v'], argsFor: (zipPath) => ['-p', zipPath] },
{ name: 'tar', probeArgs: ['--version'], argsFor: (zipPath) => ['-xOf', zipPath] },
{ name: 'C:\\Windows\\System32\\tar.exe', probeArgs: ['--version'], argsFor: (zipPath) => ['-xOf', zipPath] }
];
export function resolveZipReader() {
for (const candidate of CANDIDATE_READERS) {
try {
const probe = spawnSync(candidate.name, candidate.probeArgs, { stdio: 'ignore' });
if (!probe.error && probe.status === 0) return candidate;
} catch {
// tenta o proximo
}
}
throw new Error(
'Nenhum leitor de ZIP encontrado (unzip ou tar). Instale o Git para Windows ou verifique o PATH.'
);
}
// Abre um zip da RFB e devolve as linhas ja decodificadas de ISO-8859-1.
export function readZipLines(reader, zipPath, { spawn: spawnProcess = spawn } = {}) {
const child = spawnProcess(reader.name, reader.argsFor(zipPath), { stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = '';
let intentionalClose = false;
let settled = false;
let rl;
let resolveDone;
let rejectDone;
const done = new Promise((resolve, reject) => {
resolveDone = resolve;
rejectDone = reject;
});
const settle = (error) => {
if (settled) return;
settled = true;
if (error) rejectDone(error);
else resolveDone();
};
child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk) => {
stderr = (stderr + chunk).slice(-4096);
});
child.on('error', (error) => {
const wrapped = new Error(`falha ao iniciar leitor ZIP para ${path.basename(zipPath)}: ${error.message}`);
settle(wrapped);
rl?.close();
});
child.on('exit', (code, signal) => {
if (intentionalClose) return settle();
if (code === 0) return settle();
const error = new Error(
`leitor ZIP falhou em ${path.basename(zipPath)} (codigo ${code ?? 'n/a'}${signal ? `, sinal ${signal}` : ''}): ${stderr.slice(-1000)}`
);
settle(error);
rl?.close();
});
child.stdout.setEncoding('latin1');
rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
const streamError = (error) => settle(new Error(`erro ao ler ${path.basename(zipPath)}: ${error.message}`));
rl.on('error', streamError);
child.stdout.on('error', streamError);
return {
rl,
done,
close() {
intentionalClose = true;
rl.close();
child.kill();
}
};
}
// So o primeiro campo (antes do primeiro '";"'), sem split completo.
function firstField(line) {
const end = line.indexOf('";"', 1);
if (end === -1) return '';
return line.slice(1, end);
}
function parseQuotedRow(line) {
if (line.startsWith('"') && line.endsWith('"')) {
return line.slice(1, -1).split('";"');
}
return line.split(';').map((value) => value.replace(/^"|"$/g, ''));
}
function titleCase(name) {
return String(name).toLowerCase().replace(/(^|\s)([a-z])/g, (m, sep, ch) => sep + ch.toUpperCase());
}
async function buildTomMap(reader, rawDir) {
const zipPath = path.join(rawDir, 'Municipios.zip');
if (!fs.existsSync(zipPath)) {
throw new Error(`Municipios.zip nao encontrado em ${rawDir}`);
}
const tomToNome = new Map();
const zip = readZipLines(reader, zipPath);
for await (const line of zip.rl) {
if (!line.trim()) continue;
const [codigo, nome] = parseQuotedRow(line);
if (codigo) tomToNome.set(copyStr(codigo), copyStr(nome));
}
await zip.done;
return tomToNome;
}
async function* estabelecimentoRows(reader, rawDir, uf, { onFileProgress, isCancelled }) {
for (let i = 0; i <= 9; i += 1) {
const zipPath = path.join(rawDir, `Estabelecimentos${i}.zip`);
if (!fs.existsSync(zipPath)) continue;
const zip = readZipLines(reader, zipPath);
let lineCount = 0;
let matchCount = 0;
for await (const line of zip.rl) {
lineCount += 1;
if (lineCount % 500000 === 0 && isCancelled()) {
zip.close();
return;
}
if (!line.trim()) continue;
// Filtro barato por UF antes do parse completo: a UF aparece como
// campo '";UF";"' — o indexOf evita o split das ~29M linhas de outras UFs.
if (!line.includes(`"${uf}"`)) continue;
const cols = parseQuotedRow(line);
if (cols[19] !== uf) continue;
if (cols[5] !== '02') continue;
matchCount += 1;
yield {
b: copyStr(cols[0]), // cnpj basico
o: copyStr(cols[1]), // ordem
d: copyStr(cols[2]), // dv
f: copyStr(cols[4]), // nome fantasia
c: copyStr(cols[11]), // cnae
l: copyStr([cols[13], cols[14]].filter(Boolean).join(' ')), // logradouro
n: copyStr(cols[15]), // numero
ba: copyStr(cols[17]), // bairro
m: copyStr(cols[20]), // codigo municipio (TOM)
d1: copyStr(cols[21]),
t1: copyStr(cols[22]),
d2: copyStr(cols[23]),
t2: copyStr(cols[24])
};
}
await zip.done;
onFileProgress(`Estabelecimentos${i}.zip`, lineCount, matchCount, i);
if (global.gc) global.gc();
if (isCancelled()) return;
}
}
async function buildRazaoMap(reader, rawDir, cnpjBasicoSet, { onFileProgress, isCancelled }) {
const razaoByCnpj = new Map();
for (let i = 0; i <= 9; i += 1) {
const zipPath = path.join(rawDir, `Empresas${i}.zip`);
if (!fs.existsSync(zipPath)) continue;
const zip = readZipLines(reader, zipPath);
let lineCount = 0;
let matchCount = 0;
for await (const line of zip.rl) {
lineCount += 1;
if (lineCount % 500000 === 0 && isCancelled()) {
zip.close();
return razaoByCnpj;
}
if (!line.trim()) continue;
const cnpjBasico = firstField(line);
if (!cnpjBasico || !cnpjBasicoSet.has(cnpjBasico)) continue;
const cols = parseQuotedRow(line);
razaoByCnpj.set(copyStr(cnpjBasico), copyStr(cols[1]));
matchCount += 1;
}
await zip.done;
onFileProgress(`Empresas${i}.zip`, lineCount, matchCount, i);
if (global.gc) global.gc();
if (isCancelled()) break;
}
return razaoByCnpj;
}
export async function extractUf({
uf,
rawDir,
outCsv,
checkpointPath,
db = null,
onProgress = () => {},
isCancelled = () => false,
log = () => {}
}) {
const ufUpper = String(uf).toUpperCase();
await validateRfbRawFiles(rawDir);
const reader = resolveZipReader();
log(`leitor de zip: ${reader.name}`);
const tomToNome = await buildTomMap(reader, rawDir);
log(`${tomToNome.size} municipios no indice da RFB`);
// Nome "bonito" (IBGE, sem acento, capitalizado) por codigo TOM, para o CSV
// ficar consistente com os leads ja migrados ('Santo Antonio de Jesus').
const prettyByClean = new Map();
if (db) {
for (const row of db.prepare('SELECT nome, nome_clean FROM municipios WHERE uf = ?').all(ufUpper)) {
prettyByClean.set(row.nome_clean, stripAccents(row.nome));
}
}
const prettyName = (tomCode) => {
const tomNome = tomToNome.get(tomCode) || '';
return prettyByClean.get(cleanText(tomNome)) || titleCase(tomNome);
};
// ---- Fase 1: Estabelecimentos -> checkpoint NDJSON em disco ----
const cnpjBasicoSet = new Set();
const tomSeen = new Set();
let totalRows = 0;
let withPhone = 0;
const checkpointDone = fs.existsSync(checkpointPath) && fs.statSync(checkpointPath).size > 0;
if (checkpointDone) {
log('checkpoint encontrado: pulando varredura de Estabelecimentos');
const input = fs.createReadStream(checkpointPath, { encoding: 'utf8' });
const checkpointLines = readline.createInterface({ input, crlfDelay: Infinity });
try {
for await (const line of checkpointLines) {
if (!line.trim()) continue;
const item = JSON.parse(line);
if (!item.b || !item.m) throw new Error('checkpoint sem campos obrigatorios');
cnpjBasicoSet.add(item.b);
tomSeen.add(item.m);
totalRows += 1;
if (item.t1 || item.t2) withPhone += 1;
}
} catch (error) {
await fsp.rm(checkpointPath, { force: true });
throw new Error(`checkpoint invalido: ${error.message}`);
}
} else {
const tmpPath = `${checkpointPath}.tmp`;
const out = fs.createWriteStream(tmpPath, { encoding: 'utf8' });
let outError = null;
out.on('error', (error) => { outError = error; });
onProgress({ phase: 'estabelecimentos', pct: 0, message: 'varrendo Estabelecimentos0-9.zip' });
try {
for await (const item of estabelecimentoRows(reader, rawDir, ufUpper, {
isCancelled,
onFileProgress: (file, lines, matches, index) => {
log(`${file}: ${lines} linhas, ${matches} ativas em ${ufUpper}`);
onProgress({
phase: 'estabelecimentos',
pct: Math.round(((index + 1) / 10) * 55),
message: `${file}: ${totalRows} estabelecimentos ate agora`
});
}
})) {
cnpjBasicoSet.add(item.b);
tomSeen.add(item.m);
totalRows += 1;
if (item.t1 || item.t2) withPhone += 1;
if (!out.write(JSON.stringify(item) + '\n')) {
if (outError) throw outError;
await new Promise((resolve, reject) => {
out.once('drain', resolve);
out.once('error', reject);
});
}
}
if (outError) throw outError;
await new Promise((resolve, reject) => {
out.once('error', reject);
out.end(resolve);
});
} catch (error) {
out.destroy();
await fsp.rm(tmpPath, { force: true });
throw error;
}
if (isCancelled()) {
await fsp.rm(tmpPath, { force: true });
return { cancelled: true };
}
await fsp.rename(tmpPath, checkpointPath);
log(`checkpoint salvo: ${checkpointPath}`);
}
log(`total de estabelecimentos ativos em ${ufUpper}: ${totalRows}`);
if (!totalRows && !isCancelled()) {
await fsp.rm(checkpointPath, { force: true });
throw new Error(`nenhum estabelecimento ativo encontrado para ${ufUpper}`);
}
if (isCancelled()) return { cancelled: true };
// ---- Fase 2: Empresas -> razao social ----
onProgress({ phase: 'empresas', pct: 55, message: 'varrendo Empresas0-9.zip (razao social)' });
let razoesRecuperadas = 0;
const razaoByCnpj = await buildRazaoMap(reader, rawDir, cnpjBasicoSet, {
isCancelled,
onFileProgress: (file, lines, matches, index) => {
razoesRecuperadas += matches;
const heapMb = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(0);
log(`${file}: ${lines} linhas, ${matches} razoes sociais, heap=${heapMb}MB`);
onProgress({
phase: 'empresas',
pct: 55 + Math.round(((index + 1) / 10) * 35),
message: `${file}: ${razoesRecuperadas} razoes sociais`
});
}
});
if (isCancelled()) return { cancelled: true };
// ---- Fase 3: CSV final (streaming do checkpoint) ----
onProgress({ phase: 'csv', pct: 90, message: 'gravando CSV final' });
const headers = [
'cnpj', 'razao_social', 'nome_fantasia', 'ddd_1', 'telefone_1', 'ddd_2', 'telefone_2',
'municipio', 'municipio_codigo', 'uf', 'cnae', 'logradouro', 'numero', 'bairro'
];
const csvTmp = `${outCsv}.tmp`;
const csvOut = fs.createWriteStream(csvTmp, { encoding: 'utf8' });
let csvError = null;
csvOut.on('error', (error) => { csvError = error; });
try {
csvOut.write(BOM + headers.join(';') + '\n');
const input = fs.createReadStream(checkpointPath, { encoding: 'utf8' });
const rl = readline.createInterface({ input, crlfDelay: Infinity });
for await (const line of rl) {
if (!line.trim()) continue;
const item = JSON.parse(line);
const row = [
`${item.b}${item.o}${item.d}`,
razaoByCnpj.get(item.b) || '',
item.f,
item.d1, item.t1, item.d2, item.t2,
prettyName(item.m),
item.m,
ufUpper,
item.c,
item.l, item.n, item.ba
].map(csvEscape).join(';');
if (!csvOut.write(row + '\n')) {
if (csvError) throw csvError;
await new Promise((resolve, reject) => {
csvOut.once('drain', resolve);
csvOut.once('error', reject);
});
}
}
if (csvError) throw csvError;
await new Promise((resolve, reject) => {
csvOut.once('error', reject);
csvOut.end(resolve);
});
await fsp.rename(csvTmp, outCsv);
} catch (error) {
csvOut.destroy();
await fsp.rm(csvTmp, { force: true });
throw error;
}
// ---- Fase 4: aprende codigo TOM dos municipios no banco ----
let municipiosCount = tomSeen.size;
if (db) {
const update = db.prepare('UPDATE municipios SET codigo_tom = ? WHERE uf = ? AND nome_clean = ?');
db.exec('BEGIN');
try {
for (const tom of tomSeen) {
const nome = tomToNome.get(tom);
if (nome) update.run(tom, ufUpper, cleanText(nome));
}
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
// O checkpoint so serve para retomar uma extracao interrompida. Mantido apos
// concluir, faria uma re-extracao futura (base nova da Receita) reutilizar
// dados velhos da fase 1 — entao e apagado no sucesso.
await fsp.rm(checkpointPath, { force: true });
onProgress({ phase: 'done', pct: 100, message: `${totalRows} estabelecimentos, ${withPhone} com telefone` });
log(`CSV gravado: ${outCsv}`);
log(`com telefone: ${withPhone} (${totalRows ? ((withPhone / totalRows) * 100).toFixed(1) : 0}%)`);
return { cancelled: false, totalRows, withPhone, municipiosCount, csvPath: outCsv };
}
+36
View File
@@ -0,0 +1,36 @@
const JOB_TABLES = new Set(['capture_runs', 'extractions', 'exports']);
export function createJobLease({
db,
table,
id,
ownerToken,
pid = process.pid,
intervalMs = 30_000,
setInterval: schedule = globalThis.setInterval,
clearInterval: unschedule = globalThis.clearInterval
}) {
if (!JOB_TABLES.has(table)) throw new Error(`tabela de job invalida: ${table}`);
if (!id || !ownerToken) throw new Error('id e ownerToken obrigatorios para lease');
const startedAt = table === 'exports' ? '' : ", started_at = COALESCE(started_at, datetime('now'))";
const start = db.prepare(
`UPDATE ${table} SET status = 'running', pid = ?, owner_token = ?, heartbeat_at = datetime('now')${startedAt} WHERE id = ?`
);
const heartbeat = db.prepare(
`UPDATE ${table} SET heartbeat_at = datetime('now') WHERE id = ? AND owner_token = ? AND status = 'running'`
);
start.run(pid, ownerToken, id);
const touch = () => heartbeat.run(id, ownerToken).changes > 0;
const timer = schedule(touch, intervalMs);
timer?.unref?.();
let closed = false;
return {
ownerToken,
touch,
close() {
if (closed) return;
closed = true;
unschedule(timer);
}
};
}
+257
View File
@@ -0,0 +1,257 @@
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import readline from 'node:readline';
import path from 'node:path';
import { paths } from '../env.mjs';
import { parseJobLine } from './protocol.mjs';
const KILL_GRACE_MS = 10000;
// Gerencia os processos filhos de trabalho pesado (captura, extracao RFB,
// exportacao). O filho e dono das escritas de leads no banco (conexao
// propria, WAL); o servidor so acompanha o progresso via stdout e repassa
// para os assinantes SSE.
export function createJobManager({ spawn: spawnProcess = spawn, killGraceMs = KILL_GRACE_MS, db = null } = {}) {
const jobs = new Map();
function persistTerminal(job, status, message = null) {
if (!db) return;
const target = job.type === 'captura' && job.meta.runId
? ['capture_runs', job.meta.runId]
: job.type === 'extracao' && job.meta.extractionId
? ['extractions', job.meta.extractionId]
: job.type === 'exportacao' && job.meta.exportId
? ['exports', job.meta.exportId]
: null;
if (!target) return;
const eligibleStatuses = status === 'error' ? "'pending','running','done'" : "'pending','running'";
db.prepare(
`UPDATE ${target[0]} SET status = ?, error = ?, finished_at = datetime('now')
WHERE id = ? AND status IN (${eligibleStatuses})`
).run(status, message, target[1]);
}
function fanout(job, event) {
job.lastEvent = event;
for (const send of job.subscribers) {
try {
send(event);
} catch {
// assinante ja desconectou
}
}
}
function startJob({ type, script, args = [], meta = {}, ownerToken = randomUUID() }) {
const jobId = randomUUID();
const child = spawnProcess(
process.execPath,
[path.join(paths.root, script), `--job-id=${jobId}`, `--owner-token=${ownerToken}`, ...args],
{ cwd: paths.root, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
);
const job = {
id: jobId,
type,
meta,
child,
status: 'running',
startedAt: new Date().toISOString(),
finishedAt: null,
lastEvent: { t: 'progress', phase: type, message: 'iniciando...' },
stderrTail: '',
subscribers: new Set(),
killTimer: null,
cancelRequested: false
,ownerToken,
pendingDone: null,
exitCode: null,
exitSignal: null,
cleanupScheduled: false
};
jobs.set(jobId, job);
const failStream = (error) => {
if (job.status !== 'running') return;
job.status = job.cancelRequested ? 'cancelled' : 'error';
job.finishedAt = new Date().toISOString();
const message = `stream do processo filho falhou: ${error.message}`;
persistTerminal(job, job.status, job.status === 'error' ? message : null);
fanout(job, job.status === 'error'
? { t: 'error', message }
: { t: 'done', summary: { cancelled: true } });
try { child.kill(); } catch { /* processo ja encerrou */ }
};
const rl = readline.createInterface({ input: child.stdout });
rl.on('error', failStream);
rl.on('line', (line) => {
let event = parseJobLine(line);
if (!event) return;
if (event.t === 'done') {
job.pendingDone = event;
return;
} else if (event.t === 'error') {
job.status = job.cancelRequested ? 'cancelled' : 'error';
job.finishedAt = new Date().toISOString();
persistTerminal(job, job.status, job.status === 'error' ? event.message || 'processo filho falhou' : null);
if (job.status === 'cancelled') event = { t: 'done', summary: { cancelled: true } };
}
fanout(job, event);
});
child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk) => {
job.stderrTail = (job.stderrTail + chunk).slice(-4096);
});
child.stderr.on('error', (error) => {
job.stderrTail = (job.stderrTail + error.message).slice(-4096);
failStream(error);
});
child.stdout.on('error', (error) => {
job.stderrTail = (job.stderrTail + error.message).slice(-4096);
failStream(error);
});
child.on('error', (error) => {
if (job.status !== 'running') return;
job.status = job.cancelRequested ? 'cancelled' : 'error';
job.finishedAt = new Date().toISOString();
persistTerminal(job, job.status, job.status === 'error' ? error.message : null);
fanout(job, job.status === 'error' ? { t: 'error', message: `falha ao iniciar processo: ${error.message}` } : { t: 'done', summary: { cancelled: true } });
});
const scheduleCleanup = () => {
if (job.cleanupScheduled) return;
job.cleanupScheduled = true;
setTimeout(() => jobs.delete(jobId), 10 * 60 * 1000).unref();
};
child.on('exit', (code, signal) => {
if (job.killTimer) clearTimeout(job.killTimer);
job.exitCode = code;
job.exitSignal = signal;
});
child.on('close', (code, signal) => {
if (job.killTimer) clearTimeout(job.killTimer);
job.exitCode = code ?? job.exitCode;
job.exitSignal = signal ?? job.exitSignal;
if (job.status === 'running') {
const cleanDone = job.exitCode === 0 && job.pendingDone;
job.status = cleanDone
? (job.pendingDone.summary?.cancelled ? 'cancelled' : 'done')
: (job.cancelRequested ? 'cancelled' : 'error');
job.finishedAt = new Date().toISOString();
if (cleanDone) {
persistTerminal(job, job.status);
fanout(job, job.pendingDone);
} else if (job.status === 'cancelled') {
persistTerminal(job, 'cancelled');
fanout(job, { t: 'done', summary: { cancelled: true } });
} else {
const message = `processo encerrou inesperadamente (codigo ${job.exitCode}${job.exitSignal ? `, sinal ${job.exitSignal}` : ''}). ${job.stderrTail.slice(-500)}`;
persistTerminal(job, 'error', message);
fanout(job, { t: 'error', message });
}
}
scheduleCleanup();
});
return jobId;
}
function cancelJob(jobId) {
const job = jobs.get(jobId);
if (!job || job.status !== 'running') return false;
job.cancelRequested = true;
try {
job.child.stdin.write('cancel\n');
} catch {
// stdin ja fechado
}
job.killTimer = setTimeout(() => {
if (job.status === 'running') job.child.kill();
}, killGraceMs);
job.killTimer.unref();
return true;
}
function subscribe(jobId, send) {
const job = jobs.get(jobId);
if (!job) return null;
// replay do ultimo evento para quem conecta no meio (refresh da pagina)
send(job.lastEvent);
if (job.status !== 'running') {
return () => {};
}
job.subscribers.add(send);
return () => job.subscribers.delete(send);
}
function getJob(jobId) {
return jobs.get(jobId) || null;
}
function activeJobs() {
return [...jobs.values()]
.filter((job) => job.status === 'running')
.map((job) => ({
jobId: job.id,
type: job.type,
meta: job.meta,
startedAt: job.startedAt,
lastEvent: job.lastEvent
}));
}
async function close() {
const running = [...jobs.values()].filter((job) => job.status === 'running');
if (!running.length) return;
const exits = running.map((job) => new Promise((resolve) => job.child.once('close', resolve)));
const allExited = Promise.all(exits);
const waitDeadline = () => new Promise((resolve) => setTimeout(resolve, killGraceMs));
for (const job of running) cancelJob(job.id);
await Promise.race([allExited, waitDeadline()]);
for (const job of running) {
if (job.status === 'running') job.child.kill();
}
await Promise.race([allExited, waitDeadline()]);
for (const job of running) {
if (job.status !== 'running') continue;
if (job.killTimer) clearTimeout(job.killTimer);
job.status = 'cancelled';
job.finishedAt = new Date().toISOString();
persistTerminal(job, 'cancelled');
}
}
return { startJob, cancelJob, subscribe, getJob, activeJobs, close };
}
// No boot do servidor: execucoes marcadas como running cujo processo morreu
// (queda de energia, kill) viram erro para nao travar a interface.
export function recoverOrphans(db, log = () => {}, {
processAlive = (pid) => {
if (!pid) return false;
try { process.kill(pid, 0); return true; } catch { return false; }
},
now = Date.now(),
leaseTimeoutMs = 120_000
} = {}) {
for (const table of ['capture_runs', 'extractions', 'exports']) {
const running = db.prepare(`SELECT id, pid, heartbeat_at FROM ${table} WHERE status IN ('running', 'pending')`).all();
for (const row of running) {
const heartbeatMs = row.heartbeat_at ? Date.parse(`${row.heartbeat_at.replace(' ', 'T')}Z`) : Number.NaN;
const stale = !Number.isFinite(heartbeatMs) || now - heartbeatMs > leaseTimeoutMs;
if (!stale) continue;
db.prepare(
`UPDATE ${table} SET status = 'error', error = 'interrompido por reinicio do servidor', finished_at = datetime('now') WHERE id = ?`
).run(row.id);
log(`${table} #${row.id} marcado como interrompido`);
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import readline from 'node:readline';
// Protocolo entre o servidor e os processos filhos de job:
// - filho imprime UMA mensagem JSON por linha no stdout:
// {"t":"progress", ...} | {"t":"log","message":...} |
// {"t":"done","summary":{...}} | {"t":"error","message":...}
// - servidor escreve "cancel\n" no stdin do filho para pedir cancelamento
// cooperativo (o filho checa isCancelled() entre lotes).
export function createJobIO() {
let cancelled = false;
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', (line) => {
if (line.trim() === 'cancel') cancelled = true;
});
// stdin nao deve segurar o processo vivo apos o trabalho terminar
process.stdin.unref?.();
const write = (obj) => {
process.stdout.write(JSON.stringify(obj) + '\n');
};
return {
isCancelled: () => cancelled,
emit: (progress) => write({ t: 'progress', ...progress }),
log: (message) => write({ t: 'log', message: String(message) }),
done: (summary = {}) => write({ t: 'done', summary }),
fail: (error) => write({ t: 'error', message: String(error?.message || error) })
};
}
export function parseJobLine(line) {
try {
const obj = JSON.parse(line);
return obj && typeof obj === 'object' && obj.t ? obj : null;
} catch {
return null;
}
}
+411
View File
@@ -0,0 +1,411 @@
const TARGET_CITY_ALIASES = new Set([
'santo antonio de jesus',
'santo antonio de jesus ba',
'saj'
]);
const TARGET_SEGMENTS = new Set([
'mercado',
'supermercado',
'farmacia',
'drogaria',
'restaurante',
'delivery',
'distribuidora',
'autopecas',
'material de construcao',
'clinica',
'loja',
'varejo',
'atacado',
'oficina',
'escola',
'salao de beleza',
'barbearia',
'estetica',
'celular',
'transportadora',
'logistica'
]);
const SEGMENT_STOPWORDS = new Set(['de', 'da', 'do', 'e']);
function segmentTokens(value = '') {
return cleanText(value)
.split(/[^a-z0-9]+/)
.filter((token) => token.length > 2 && !SEGMENT_STOPWORDS.has(token));
}
const TARGET_SEGMENT_TOKENS = new Set(
[...TARGET_SEGMENTS].flatMap((term) => segmentTokens(term))
);
function tokenMatches(token, targetToken) {
return token === targetToken
|| (token.length > 4 && targetToken.length > 4 && (token.startsWith(targetToken) || targetToken.startsWith(token)));
}
function isTargetSegmentMatch(segmentKey) {
if (!segmentKey) return false;
if (TARGET_SEGMENTS.has(segmentKey)) return true;
return segmentTokens(segmentKey).some((token) =>
[...TARGET_SEGMENT_TOKENS].some((targetToken) => tokenMatches(token, targetToken))
);
}
const SERVICE_LABELS = {
erp: 'ERP / sistema de gestao',
ti: 'Gestao de TI',
whatsapp: 'Autoatendimento para WhatsApp',
indefinido: 'A definir'
};
const URGENCY_LABELS = {
alta: 'Alta',
media: 'Media',
baixa: 'Baixa',
indefinida: 'Indefinida'
};
export function stripAccents(value = '') {
return String(value)
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
export function cleanText(value = '') {
return stripAccents(value)
.toLowerCase()
.replace(/[^a-z0-9\s@._+-]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function firstDefined(...values) {
return values.find((value) => value !== undefined && value !== null && value !== '') ?? '';
}
function numberFrom(value) {
if (value === undefined || value === null || value === '') return 0;
if (typeof value === 'number' && Number.isFinite(value)) return value;
const match = String(value).match(/\d+/);
return match ? Number(match[0]) : 0;
}
function toArray(value) {
if (Array.isArray(value)) return value.filter(Boolean).map(String);
if (!value) return [];
return String(value)
.split(/[,;|]/)
.map((item) => item.trim())
.filter(Boolean);
}
export function normalizePhone(value = '') {
let digits = String(value).replace(/\D/g, '');
while (digits.startsWith('0')) digits = digits.slice(1);
if (digits.length === 10 || digits.length === 11) {
digits = `55${digits}`;
}
return digits;
}
export function detectService(value = '') {
const text = cleanText(value);
if (!text) return 'indefinido';
if (/\b(1|erp|sistema|gestao empresarial|estoque|financeiro|fiscal|nfce|nfe|pdv)\b/.test(text)) {
return 'erp';
}
if (/\b(2|ti|suporte|servidor|rede|backup|seguranca|computador|infraestrutura)\b/.test(text)) {
return 'ti';
}
if (/\b(3|whatsapp|autoatendimento|chatbot|bot|atendimento automatico|mensagem)\b/.test(text)) {
return 'whatsapp';
}
return 'indefinido';
}
export function normalizeUrgency(value = '') {
const text = cleanText(value);
if (!text) return 'indefinida';
if (/\b(alta|urgente|imediato|hoje|agora|essa semana|parado|problema serio|1)\b/.test(text)) return 'alta';
if (/\b(media|medio|proximo mes|em breve|avaliando|2)\b/.test(text)) return 'media';
if (/\b(baixa|sem pressa|futuro|pesquisa|so pesquisando|3)\b/.test(text)) return 'baixa';
return 'indefinida';
}
export function normalizeCurrentSystem(value = '') {
const text = cleanText(value);
if (!text) return 'nao_informado';
if (/\b(trocar|insatisfeito|migrar|outro sistema|uso outro|4)\b/.test(text)) return 'quer_trocar';
if (/\b(planilha|excel|manual|papel|3)\b/.test(text)) return 'planilhas';
if (/\b(sim|tenho|uso sistema|ja uso|1)\b/.test(text)) return 'usa_sistema';
if (/\b(nao|nenhum|nao tenho|sem sistema|2)\b/.test(text)) return 'sem_sistema';
return 'nao_informado';
}
export function normalizeLead(input = {}) {
const body = input.body && typeof input.body === 'object' ? input.body : input;
const contact = body.contact ?? body.contato ?? {};
const company = body.company ?? body.empresa_dados ?? {};
const message = body.message ?? body.mensagem ?? body.text ?? body.texto ?? body.description ?? '';
const serviceRaw = firstDefined(
body.service,
body.servico,
body.interest,
body.interesse,
body.solution,
body.solucao,
message
);
const city = firstDefined(company.city, company.cidade, body.city, body.cidade);
const state = firstDefined(company.state, company.uf, body.state, body.uf, body.estado);
const segment = firstDefined(company.segment, company.segmento, body.segment, body.segmento);
const phone = normalizePhone(firstDefined(contact.phone, contact.whatsapp, body.phone, body.telefone, body.whatsapp, body.from));
const name = firstDefined(contact.name, contact.nome, body.name, body.nome, body.contact_name, 'Lead sem nome');
const email = firstDefined(contact.email, body.email);
const companyName = firstDefined(company.name, company.nome, body.company_name, body.empresa, body.nome_empresa, 'Empresa nao informada');
const pains = toArray(firstDefined(body.pains, body.dores, body.pain, body.dor, body.maior_dificuldade));
const source = firstDefined(body.source, body.origem, body.canal, 'indefinida');
const service = detectService(serviceRaw);
const urgency = normalizeUrgency(firstDefined(body.urgency, body.urgencia, body.prazo, message));
const currentSystem = normalizeCurrentSystem(firstDefined(body.current_system, body.sistema_atual, body.usa_sistema, body.sistema, message));
const employees = numberFrom(firstDefined(company.employees, company.funcionarios, body.employees, body.funcionarios));
const computers = numberFrom(firstDefined(company.computers, company.computadores, body.computers, body.computadores, body.usuarios));
const whatsappVolume = numberFrom(firstDefined(body.whatsapp_volume, body.volume_whatsapp, body.mensagens_dia));
const consent = Boolean(firstDefined(body.consent, body.consentimento, body.opt_in, body.lgpd, false));
const cityKey = cleanText(city);
const segmentKey = cleanText(segment);
return {
id: body.id ?? body.lead_id ?? `MCP-${Date.now()}-${Math.random().toString(36).slice(2, 8).toUpperCase()}`,
createdAt: body.created_at ?? body.createdAt ?? new Date().toISOString(),
source: String(source || 'indefinida'),
name: String(name),
phone,
email: String(email || ''),
companyName: String(companyName),
city: String(city || ''),
state: String(state || ''),
segment: String(segment || ''),
service,
serviceLabel: SERVICE_LABELS[service],
urgency,
urgencyLabel: URGENCY_LABELS[urgency],
currentSystem,
pains,
message: String(message || ''),
employees,
computers,
whatsappVolume,
consent,
isLocal: TARGET_CITY_ALIASES.has(cityKey) || (cityKey.includes('santo antonio') && cityKey.includes('jesus')),
isTargetSegment: isTargetSegmentMatch(segmentKey),
raw: body
};
}
export function prioritizeLead(lead) {
const hasWhatsapp = Boolean(lead.phone);
return {
priority: hasWhatsapp ? 'com_whatsapp' : 'sem_whatsapp',
hasWhatsapp,
reasons: hasWhatsapp ? ['Possui WhatsApp/telefone para contato'] : []
};
}
export function buildNextQuestion(lead) {
if (lead.service === 'indefinido') {
return [
'Para te direcionar melhor, escolha uma opcao:',
'1 - Sistema ERP / gestao empresarial',
'2 - Suporte e gestao de TI',
'3 - Autoatendimento para WhatsApp',
'4 - Ainda nao sei, preciso de orientacao'
].join('\n');
}
if (lead.service === 'erp') {
if (!lead.segment) {
return [
'Qual o segmento da sua empresa?',
'1 - Comercio / loja',
'2 - Restaurante / delivery',
'3 - Mercado / supermercado',
'4 - Distribuidora',
'5 - Servicos',
'6 - Outro'
].join('\n');
}
if (lead.currentSystem === 'nao_informado') {
return [
'Hoje voce ja usa algum sistema?',
'1 - Sim',
'2 - Nao',
'3 - Uso planilhas',
'4 - Uso outro sistema, mas quero trocar'
].join('\n');
}
if (!lead.pains.length) {
return [
'Qual sua maior dificuldade hoje?',
'1 - Controle financeiro',
'2 - Estoque',
'3 - Vendas',
'4 - Emissao fiscal',
'5 - Relatorios',
'6 - Tudo muito manual'
].join('\n');
}
}
if (lead.service === 'ti') {
if (!lead.computers) {
return [
'Sua empresa possui quantos computadores ou usuarios?',
'1 - Ate 5',
'2 - 6 a 15',
'3 - 16 a 30',
'4 - Mais de 30'
].join('\n');
}
if (!lead.pains.length) {
return [
'Qual sua maior necessidade de TI?',
'1 - Suporte tecnico',
'2 - Servidores',
'3 - Rede / internet',
'4 - Backup',
'5 - Seguranca',
'6 - Terceirizar o TI'
].join('\n');
}
}
if (lead.service === 'whatsapp') {
if (!lead.pains.length) {
return [
'Voce quer automatizar qual tipo de atendimento?',
'1 - Vendas',
'2 - Suporte',
'3 - Agendamento',
'4 - Cardapio / pedidos',
'5 - Triagem de mensagens',
'6 - Financeiro / segunda via'
].join('\n');
}
if (!lead.whatsappVolume) {
return [
'Em media, quantas mensagens chegam no WhatsApp da empresa por dia?',
'1 - Ate 20',
'2 - 21 a 50',
'3 - 51 a 100',
'4 - Mais de 100'
].join('\n');
}
}
if (lead.urgency === 'indefinida') {
return [
'Qual a urgencia para resolver isso?',
'1 - Alta, preciso resolver agora',
'2 - Media, quero avaliar para breve',
'3 - Baixa, estou pesquisando'
].join('\n');
}
return [
'Pelo que voce informou, faz sentido um diagnostico rapido.',
'Escolha o melhor horario:',
'1 - Hoje pela manha',
'2 - Hoje a tarde',
'3 - Amanha pela manha',
'4 - Amanha a tarde'
].join('\n');
}
export function buildInternalWhatsAppMessage(lead, priorityInfo) {
const pains = lead.pains.length ? lead.pains.join(', ') : 'Nao informado';
const phone = lead.phone || 'Nao informado';
const priority = priorityInfo.priority === 'com_whatsapp' ? 'COM WHATSAPP' : 'SEM WHATSAPP';
return [
`Novo lead captado - ${priority}`,
'',
`Empresa: ${lead.companyName}`,
`Contato: ${lead.name}`,
`WhatsApp: ${phone}`,
`Cidade/UF: ${lead.city || 'Nao informada'}${lead.state ? `/${lead.state}` : ''}`,
`Segmento: ${lead.segment || 'Nao informado'}`,
`Origem: ${lead.source}`,
`Dores: ${pains}`,
'',
'Acao recomendada: fazer o tratamento manual.'
].join('\n');
}
export function buildCrmRecord(lead, priorityInfo) {
return {
lead_id: lead.id,
data_criacao: lead.createdAt,
status: 'Captado',
prioridade: priorityInfo.priority,
tem_whatsapp: Boolean(lead.phone),
origem: lead.source,
servico: lead.service,
servico_label: lead.serviceLabel,
empresa: lead.companyName,
contato: lead.name,
whatsapp: lead.phone,
email: lead.email,
cidade: lead.city,
uf: lead.state,
segmento: lead.segment,
urgencia: lead.urgency,
sistema_atual: lead.currentSystem,
dores: lead.pains.join('; '),
funcionarios: lead.employees,
computadores: lead.computers,
volume_whatsapp: lead.whatsappVolume,
consentimento: lead.consent,
proxima_acao: 'Tratamento manual',
observacao: lead.message
};
}
export function processLead(input = {}, options = {}) {
const lead = normalizeLead(input);
const priority = prioritizeLead(lead);
const commercialWhatsApp = options.commercialWhatsApp || process.env.PERSONAL_COMMERCIAL_WHATSAPP || '{{PERSONAL_COMMERCIAL_WHATSAPP}}';
return {
lead,
priority,
crmRecord: buildCrmRecord(lead, priority),
whatsapp: {
internal: {
to: commercialWhatsApp,
message: buildInternalWhatsAppMessage(lead, priority)
}
},
nextAction: 'tratamento_manual'
};
}
+69
View File
@@ -0,0 +1,69 @@
export const CAMPAIGN_PRODUCTS = ['GTI Vendas', 'G-Delivery'];
export const CAMPAIGN_STAGES = [
'fila_preparada',
'aprovado_para_envio',
'enviado',
'respondeu',
'interessado_gti_vendas',
'interessado_g_delivery',
'demonstracao_agendada',
'teste_g_delivery_iniciado',
'sem_interesse',
'opt_out',
'venda_fechada'
];
const GTI_VENDAS_VARIANTS = [
{
code: 'A',
mensagem: 'Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Hoje voces controlam {foco_gestao} em um unico sistema? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.'
},
{
code: 'B',
mensagem: 'Ola! Aqui e Gustavo, do Grupo GTI. Trabalhamos com gestao para empresas de Santo Antonio de Jesus e regiao. Como voces organizam hoje {foco_gestao}? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.'
},
{
code: 'C',
mensagem: 'Ola, tudo bem? Sou Gustavo, do Grupo GTI. Ha mais de 7 anos atendemos empresas da regiao e ja temos mais de 100 clientes. Voces utilizam algum sistema para {foco_gestao}? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.'
}
];
const G_DELIVERY_VARIANTS = [
{
code: 'A',
mensagem: 'Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Queria entender como voces recebem os pedidos de delivery hoje: possuem um canal proprio ou dependem de aplicativos que cobram comissao? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.'
},
{
code: 'B',
mensagem: 'Ola! Aqui e Gustavo, do Grupo GTI. Voces ja possuem um link proprio para receber pedidos, sem pagar comissao por venda a aplicativos? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.'
},
{
code: 'C',
mensagem: 'Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Trabalho com uma solucao de pedidos online por canal proprio e queria primeiro entender como funciona o delivery de voces hoje. Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.'
}
];
export const CAMPAIGN_DEFINITIONS = [
{
code: 'gdelivery-food',
name: 'G-Delivery - Alimentacao',
produto: 'G-Delivery',
segments: ['hamburguerias', 'pizzarias', 'acaiterias', 'marmitarias', 'lanchonetes'],
variants: G_DELIVERY_VARIANTS
},
{
code: 'gti-food',
name: 'GTI Vendas - Alimentacao e Mercados',
produto: 'GTI Vendas',
segments: ['restaurantes', 'mercados_de_bairro', 'mercearias', 'minimercados', 'padarias', 'conveniencias'],
variants: GTI_VENDAS_VARIANTS
},
{
code: 'gti-retail',
name: 'GTI Vendas - Varejo e Servicos',
produto: 'GTI Vendas',
segments: ['lojas_de_roupa', 'material_de_construcao', 'autopecas', 'oficinas_mecanicas'],
variants: GTI_VENDAS_VARIANTS
}
];
+97
View File
@@ -0,0 +1,97 @@
import { isIP } from 'node:net';
const MARKET_SEGMENTS = new Set([
'mercados_de_bairro',
'mercearias',
'minimercados',
'padarias',
'conveniencias'
]);
const CNPJ_NUMBER = /\b\d{2}\.?\d{3}\.?\d{3}\/?\d{4}-?\d{2}\b/;
const URL = /\b(?:https?:\/\/|www\.)\S+|\b(?:[a-z\d](?:[a-z\d-]*[a-z\d])?\.)+[a-z]{2,}(?:\/\S*)?/i;
const LOCALHOST = /\blocalhost(?::\d{1,5})?(?:\/\S*)?/i;
const ABSOLUTE_PATH = /(?:^|\s)\/[\p{L}\d][^\s]*/u;
const PLACEHOLDER = /\{[^{}]*\}/;
const GENERIC_NAMES = new Set([
'comercio',
'empresa',
'estabelecimento',
'loja',
'nao informado',
'nome fantasia',
'nome empresarial',
'razao social',
'sem nome'
]);
function normalizedText(value) {
return String(value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
function containsIpAddress(value) {
const text = String(value);
const ipv4Candidates = text.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g) ?? [];
if (ipv4Candidates.some((candidate) => isIP(candidate) === 4)) return true;
const ipv6Candidates = text.match(/\[?[\da-f]*:[\da-f:]+\]?/gi) ?? [];
return ipv6Candidates.some((candidate) => isIP(candidate.replace(/^\[|\]$/g, '')) === 6);
}
export function naturalBusinessName(lead) {
const name = String(lead?.empresa ?? '').trim();
if (!name || name.length > 60 || /^\d/.test(name) || CNPJ_NUMBER.test(name)) return '';
if (/[{}]|[\p{Cc}\p{Cf}]/u.test(name)) return '';
if (/\b(?:ltda|eireli|mei|epp|me|s\/?a)\.?$/i.test(name)) return '';
if (GENERIC_NAMES.has(normalizedText(name))) return '';
const letters = name.match(/\p{L}/gu)?.length ?? 0;
const digits = name.match(/\d/g)?.length ?? 0;
if (letters < 2 || digits >= letters) return '';
return name;
}
export function managementFocus(segment) {
if (segment === 'restaurantes') return 'vendas, estoque, comandas e emissão fiscal';
if (MARKET_SEGMENTS.has(segment)) return 'vendas, estoque e emissão fiscal';
if (segment === 'oficinas_mecanicas') return 'ordens de serviço, estoque e financeiro';
return 'vendas, estoque, financeiro e notas fiscais';
}
export function variantForLead(variants, leadId) {
const activeVariants = variants.filter((variant) => variant.ativo !== 0 && variant.ativo !== false);
if (activeVariants.length === 0) throw new Error('nenhuma variante ativa');
const numericLeadId = Number(leadId);
if (!Number.isSafeInteger(numericLeadId) || numericLeadId < 0) throw new Error('leadId invalido');
return activeVariants[numericLeadId % activeVariants.length];
}
export function renderCampaignMessage(template, lead) {
const source = typeof template === 'string' ? template : template?.mensagem;
if (typeof source !== 'string') throw new Error('template de campanha invalido');
let rendered = source.replaceAll('{foco_gestao}', managementFocus(lead?.segmento ?? lead?.segment));
const businessName = naturalBusinessName(lead);
if (businessName) {
rendered = rendered.replace(/^(?:Ola|Olá)(?:, tudo bem\?|!)/, `Olá, pessoal da ${businessName}, tudo bem?`);
}
const normalized = normalizedText(rendered);
const unsafe = URL.test(rendered)
|| LOCALHOST.test(rendered)
|| containsIpAddress(rendered)
|| ABSOLUTE_PATH.test(rendered)
|| PLACEHOLDER.test(rendered)
|| CNPJ_NUMBER.test(rendered)
|| /R\$/i.test(rendered)
|| !rendered.includes('Gustavo')
|| !rendered.includes('Grupo GTI')
|| !normalized.includes('nao volto a entrar em contato');
if (unsafe) throw new Error('mensagem inicial insegura');
return rendered;
}
+281
View File
@@ -0,0 +1,281 @@
export function campaignMetrics(db, campaignId, filters = {}) {
const { segmento, variant } = filters;
let queueWhere = 'WHERE q.campaign_id = ?';
const queueParams = [campaignId];
if (segmento) {
queueWhere += ' AND q.segmento = ?';
queueParams.push(segmento);
}
if (variant) {
queueWhere += ' AND v.code = ?';
queueParams.push(variant);
}
const queueJoin = variant
? 'JOIN outreach_campaign_variants v ON v.id = q.variant_id AND v.campaign_id = q.campaign_id'
: '';
const queue = db.prepare(
`SELECT q.status, COUNT(*) AS total
FROM outreach_campaign_queue q ${queueJoin} ${queueWhere}
GROUP BY q.status`
).all(...queueParams);
const queueMap = {};
for (const row of queue) queueMap[row.status] = row.total;
const sends = db.prepare(
`SELECT s.status, COUNT(*) AS total
FROM outreach_sends s
JOIN outreach_campaign_queue q ON q.outreach_send_id = s.id
${queueJoin}
${queueWhere}
GROUP BY s.status`
).all(...queueParams);
const leadIdsResult = db.prepare(
`SELECT DISTINCT q.lead_id
FROM outreach_campaign_queue q ${queueJoin} ${queueWhere}`
).all(...queueParams);
const leadIds = leadIdsResult.map((r) => r.lead_id);
const replies = db.prepare(
`SELECT COUNT(DISTINCT r.lead_id) AS total
FROM outreach_inbound_replies r
JOIN outreach_campaign_queue q ON q.lead_id = r.lead_id
${queueJoin}
${queueWhere}`
).get(...queueParams).total;
let firstResponseTimeAvg = null;
if (replies > 0) {
const times = db.prepare(
`SELECT MIN(r.received_at) AS first_reply,
MIN(s.sent_at) AS first_send
FROM outreach_inbound_replies r
JOIN outreach_sends s ON s.lead_id = r.lead_id AND s.origem = 'automatico'
JOIN outreach_campaign_queue q ON q.outreach_send_id = s.id
${queueJoin}
${queueWhere}
AND s.sent_at IS NOT NULL
GROUP BY r.lead_id
HAVING first_reply IS NOT NULL AND first_send IS NOT NULL`
).all(...queueParams);
if (times.length > 0) {
let total = 0;
for (const t of times) {
total += (new Date(t.first_reply).getTime() - new Date(t.first_send).getTime()) / 3600000;
}
firstResponseTimeAvg = total / times.length;
}
}
const stageRows = db.prepare(
`SELECT ls.stage, COUNT(DISTINCT ls.lead_id) AS total
FROM outreach_lead_states ls
JOIN outreach_campaign_queue q ON q.lead_id = ls.lead_id
${queueJoin}
${queueWhere}
GROUP BY ls.stage`
).all(...queueParams);
let positiveStages = 0;
let negativeStages = 0;
let optOutStages = 0;
for (const row of stageRows) {
if (['interessado_gti_vendas', 'interessado_g_delivery', 'venda_fechada'].includes(row.stage)) {
positiveStages += row.total;
} else if (row.stage === 'sem_interesse') {
negativeStages += row.total;
} else if (row.stage === 'opt_out') {
optOutStages += row.total;
}
}
const appRows = db.prepare(
`SELECT a.kind, COUNT(DISTINCT a.rowid) AS total
FROM outreach_appointments a
JOIN outreach_campaign_queue q ON q.lead_id = a.lead_id
${queueJoin}
${queueWhere}
GROUP BY a.kind`
).all(...queueParams);
let demos = 0;
let tests = 0;
for (const row of appRows) {
if (row.kind === 'demonstracao') demos += row.total;
else if (row.kind === 'teste_g_delivery') tests += row.total;
}
const sales = Number(db.prepare(
`SELECT COUNT(DISTINCT ls.lead_id) AS total
FROM outreach_lead_states ls
JOIN outreach_campaign_queue q ON q.lead_id = ls.lead_id
${queueJoin}
${queueWhere}
AND ls.stage = 'venda_fechada'`
).get(...queueParams).total);
const sendMap = { aceito: 0, erro: 0, desconhecido: 0 };
for (const row of sends) {
if (row.status in sendMap) sendMap[row.status] = row.total;
}
const groups = {
byProduct: {},
bySegment: {},
byVariant: {}
};
const groupRows = db.prepare(
`SELECT q.produto, q.segmento, v.code AS variant_code,
COUNT(*) AS total
FROM outreach_campaign_queue q
JOIN outreach_campaign_variants v ON v.id = q.variant_id AND v.campaign_id = q.campaign_id
${queueWhere}
GROUP BY q.produto, q.segmento, v.code`
).all(...queueParams);
for (const row of groupRows) {
const total = row.total;
if (!groups.byProduct[row.produto]) {
groups.byProduct[row.produto] = { sends: 0 };
}
groups.byProduct[row.produto].sends += total;
if (!groups.bySegment[row.segmento]) {
groups.bySegment[row.segmento] = { sends: 0 };
}
groups.bySegment[row.segmento].sends += total;
if (!groups.byVariant[row.variant_code]) {
groups.byVariant[row.variant_code] = { sends: 0, eligibleForComparison: false };
}
groups.byVariant[row.variant_code].sends += total;
}
for (const variantCode of Object.keys(groups.byVariant)) {
const v = groups.byVariant[variantCode];
const sendCount = db.prepare(
`SELECT COUNT(DISTINCT s.id) AS total
FROM outreach_sends s
JOIN outreach_campaign_queue q ON q.outreach_send_id = s.id
JOIN outreach_campaign_variants v ON v.id = q.variant_id AND v.code = ? AND v.campaign_id = q.campaign_id
${queueWhere}
AND s.origem = 'automatico' AND s.status IN ('aceito','erro','desconhecido')`
).get(variantCode, ...queueParams).total;
v.eligibleForComparison = sendCount >= 40;
v.sends = sendCount;
}
return {
previewed: queueMap.preview || 0,
approved: queueMap.approved || 0,
removed: queueMap.cancelled || 0,
sent: queueMap.sent || 0,
attempted: (queueMap.scheduled || 0) + (queueMap.reserved || 0) + (queueMap.sent || 0) + (queueMap.unknown || 0) + (queueMap.error || 0),
provider: sendMap,
replies,
firstResponseTimeAvg,
stages: { positive: positiveStages, negative: negativeStages, optOut: optOutStages },
appointments: { demos, tests },
sales,
groups
};
}
export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
const row = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId);
if (!row) return { pause: false, reason: null };
const unknownCount = Number(db.prepare(
`SELECT COUNT(*) AS total FROM outreach_campaign_queue
WHERE campaign_id = ? AND status = 'unknown'`
).get(campaignId).total);
if (unknownCount > 0) {
applyPause(db, campaignId, 'unknown_outcome');
return { pause: true, reason: 'unknown_outcome' };
}
const todayKey = dateKey(now);
const attemptsToday = Number(db.prepare(
`SELECT COUNT(DISTINCT s.id) AS total
FROM outreach_sends s
JOIN outreach_campaign_queue q ON q.lead_id = s.lead_id AND q.campaign_id = ?
WHERE s.origem = 'automatico'
AND s.status IN ('aceito', 'erro')
AND date(s.sent_at, 'localtime') = ?`
).get(campaignId, todayKey).total);
if (attemptsToday >= 10) {
const failureCount = Number(db.prepare(
`SELECT COUNT(DISTINCT s.id) AS total
FROM outreach_sends s
JOIN outreach_campaign_queue q ON q.lead_id = s.lead_id AND q.campaign_id = ?
WHERE s.origem = 'automatico'
AND s.status = 'erro'
AND date(s.sent_at, 'localtime') = ?`
).get(campaignId, todayKey).total);
const successCount = attemptsToday - failureCount;
const failureRate = successCount + failureCount > 0 ? failureCount / (successCount + failureCount) : 0;
if (failureRate > 0.10) {
applyPause(db, campaignId, 'high_failure_rate');
return { pause: true, reason: 'high_failure_rate' };
}
}
const contacts = Number(db.prepare(
`SELECT COUNT(DISTINCT q.lead_id) AS total
FROM outreach_campaign_queue q
WHERE q.campaign_id = ?
AND q.status IN ('sent', 'unknown', 'error')`
).get(campaignId).total);
if (contacts >= 20) {
const negativeOrOptOutCount = Number(db.prepare(
`SELECT COUNT(DISTINCT ls.lead_id) AS total
FROM outreach_lead_states ls
JOIN outreach_campaign_queue q ON q.lead_id = ls.lead_id AND q.campaign_id = ?
WHERE ls.stage IN ('sem_interesse', 'opt_out')
AND q.status IN ('sent', 'unknown', 'error')`
).get(campaignId).total);
const rate = contacts > 0 ? negativeOrOptOutCount / contacts : 0;
if (rate > 0.10) {
applyPause(db, campaignId, 'high_negative_rate');
return { pause: true, reason: 'high_negative_rate' };
}
}
if (contacts >= 50) {
const replyCount = Number(db.prepare(
`SELECT COUNT(DISTINCT r.lead_id) AS total
FROM outreach_inbound_replies r
JOIN outreach_campaign_queue q ON q.lead_id = r.lead_id AND q.campaign_id = ?
WHERE q.status IN ('sent', 'unknown', 'error')`
).get(campaignId).total);
const replyRate = contacts > 0 ? replyCount / contacts : 0;
if (replyRate < 0.05) {
applyPause(db, campaignId, 'low_reply_rate');
return { pause: true, reason: 'low_reply_rate' };
}
}
return { pause: false, reason: null };
}
function dateKey(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function applyPause(db, campaignId, reason) {
const row = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId);
if (!row || row.status !== 'running') return;
db.prepare(
`UPDATE outreach_campaigns SET status = 'paused', pause_reason = ?, updated_at = datetime('now') WHERE id = ?`
).run(reason, campaignId);
}
+217
View File
@@ -0,0 +1,217 @@
import {
isOptedOut,
saveWhatsAppVerification
} from '../db.mjs';
import { equivalentPhoneNumbers, normalizePhone } from '../phone.mjs';
import { getCampaign } from './campaign-store.mjs';
import { renderCampaignMessage, variantForLead } from './campaign-messages.mjs';
const ACTIVE_QUEUE_STATUSES = "'preview','approved','scheduled','reserved','sent','unknown'";
const PENDING_QUEUE_STATUSES = "'preview','approved','scheduled','reserved'";
function normalizedSql(column) {
return `replace(replace(replace(replace(replace(${column}, '+', ''), ' ', ''), '-', ''), '(', ''), ')', '')`;
}
function equivalentSql(column) {
const number = normalizedSql(column);
return `CASE
WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 13 AND substr(${number}, 5, 1) = '9'
THEN substr(${number}, 1, 4) || substr(${number}, 6)
WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 12 AND substr(${number}, 5, 1) BETWEEN '6' AND '9'
THEN substr(${number}, 1, 4) || '9' || substr(${number}, 5)
ELSE ${number}
END`;
}
function candidateQuery(confirmed) {
const leadNumber = normalizedSql('leads.whatsapp');
const leadEquivalent = equivalentSql('leads.whatsapp');
const verificationFilter = confirmed
? "verification.status = 'confirmado'"
: "(verification.status IS NULL OR verification.status = 'erro_temporario')";
return `/* campaign-candidates */
SELECT leads.id, leads.empresa, leads.legal_name, leads.whatsapp, leads.segmento,
leads.prioridade, leads.score,
verification.status AS verification_status,
verification.numero AS verification_number,
verification.numero_resolvido AS verification_resolved_number
FROM leads
LEFT JOIN whatsapp_number_verifications verification
ON verification.rowid = (
SELECT latest.rowid
FROM whatsapp_number_verifications latest
WHERE latest.numero IN (${leadNumber}, ${leadEquivalent})
ORDER BY latest.checked_at DESC, latest.rowid DESC
LIMIT 1
)
WHERE leads.segmento = ?
AND ${leadNumber} <> ''
AND ${verificationFilter}
AND NOT EXISTS (
SELECT 1 FROM outreach_sends sends
WHERE sends.lead_id = leads.id AND sends.produto = ?
)
AND NOT EXISTS (
SELECT 1
FROM outreach_campaign_queue queued
WHERE queued.lead_id = leads.id
AND queued.produto = ?
AND queued.status IN (${ACTIVE_QUEUE_STATUSES})
)
AND NOT EXISTS (
SELECT 1 FROM outreach_opt_outs opt_out
WHERE opt_out.numero IN (${leadNumber}, ${leadEquivalent})
)
AND NOT EXISTS (
SELECT 1 FROM outreach_inbound_replies replies
WHERE replies.lead_id = leads.id
OR replies.numero IN (${leadNumber}, ${leadEquivalent})
)
AND NOT EXISTS (
SELECT 1 FROM outreach_lead_states states
WHERE states.lead_id = leads.id AND states.first_reply_at IS NOT NULL
)
ORDER BY leads.prioridade ASC, leads.score DESC, leads.id ASC
LIMIT ?`;
}
function orderedCandidates(db, campaign, { confirmed, limit }) {
if (limit <= 0) return [];
const statement = db.prepare(candidateQuery(confirmed));
const candidates = campaign.segments.flatMap((segment) => statement.all(
segment,
campaign.produto,
campaign.produto,
limit
));
candidates.sort((left, right) => left.prioridade - right.prioridade
|| right.score - left.score
|| left.id - right.id);
return candidates.slice(0, limit);
}
function verificationGroup(number) {
return equivalentPhoneNumbers(number).slice().sort().join(':');
}
function persistLookup(db, requestedNumber, result, error = null) {
const registered = result?.registered === true || result?.status === 'confirmado';
const status = error || result?.status === 'erro_temporario'
? 'erro_temporario'
: registered ? 'confirmado' : 'nao_cadastrado';
return saveWhatsAppVerification(db, {
...result,
numero: requestedNumber,
status,
erro: status === 'erro_temporario' ? (error || result?.erro || 'falha temporaria') : null
});
}
function insertPreview(db, campaign, lead, verification) {
const number = normalizePhone(verification.numero_resolvido || verification.numero || lead.whatsapp);
if (!number || isOptedOut(db, number)) return false;
const variant = variantForLead(campaign.variants, lead.id);
const renderedMessage = renderCampaignMessage(variant, lead);
db.exec('BEGIN IMMEDIATE');
try {
const pending = db.prepare(
`SELECT COUNT(*) AS total
FROM outreach_campaign_queue
WHERE campaign_id = ? AND status IN (${PENDING_QUEUE_STATUSES})`
).get(campaign.id).total;
if (pending >= campaign.approved_daily_limit) {
db.exec('COMMIT');
return false;
}
const result = db.prepare(
`INSERT OR IGNORE INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message,
selection_reason, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'preview')`
).run(
campaign.id,
lead.id,
variant.id,
campaign.produto,
number,
lead.segmento,
renderedMessage,
`${campaign.code}:prioridade-${lead.prioridade}`
);
db.exec('COMMIT');
return result.changes === 1;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
export async function prepareDailyQueue({ db, whatsapp, campaignId, now }) {
void now;
const campaign = getCampaign(db, campaignId);
if (!campaign) throw new Error('campanha nao encontrada');
if (!Array.isArray(campaign.segments) || campaign.segments.length === 0) {
return { checked: 0, previewed: 0, skipped: 0 };
}
const pending = db.prepare(
`SELECT COUNT(*) AS total
FROM outreach_campaign_queue
WHERE campaign_id = ? AND status IN (${PENDING_QUEUE_STATUSES})`
).get(campaign.id).total;
const available = Math.max(0, campaign.approved_daily_limit - pending);
if (available === 0) return { checked: 0, previewed: 0, skipped: 0 };
const checkLimit = Math.ceil(campaign.approved_daily_limit * 1.5);
const confirmedCandidates = orderedCandidates(db, campaign, { confirmed: true, limit: available });
const uncheckedCandidates = confirmedCandidates.length < available
? orderedCandidates(db, campaign, { confirmed: false, limit: checkLimit })
: [];
const candidates = [...confirmedCandidates, ...uncheckedCandidates];
const attemptedGroups = new Set();
let checked = 0;
let previewed = 0;
let skipped = 0;
for (const lead of candidates) {
if (previewed >= available) break;
let verification = lead.verification_status === 'confirmado'
? {
numero: lead.verification_number,
status: lead.verification_status,
numero_resolvido: lead.verification_resolved_number
}
: null;
if (!verification) {
if (checked >= checkLimit) break;
const group = verificationGroup(lead.whatsapp);
if (group && attemptedGroups.has(group)) {
skipped += 1;
continue;
}
if (group) attemptedGroups.add(group);
checked += 1;
try {
const result = await whatsapp.checkNumber(lead.whatsapp);
verification = persistLookup(db, lead.whatsapp, result);
} catch (error) {
persistLookup(db, lead.whatsapp, error, error);
skipped += 1;
continue;
}
if (verification.status !== 'confirmado') {
skipped += 1;
continue;
}
}
if (insertPreview(db, campaign, lead, verification)) previewed += 1;
else skipped += 1;
}
return { checked, previewed, skipped };
}
+99
View File
@@ -0,0 +1,99 @@
const MAX_DAILY_SLOTS = 18;
const HOUR_MS = 60 * 60 * 1000;
export class CampaignScheduleValidationError extends Error {}
function invalidSchedule(message) {
throw new CampaignScheduleValidationError(message);
}
function localDateParts(date) {
if (date instanceof Date && !Number.isNaN(date.getTime())) {
return [date.getFullYear(), date.getMonth(), date.getDate()];
}
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(date || ''));
if (!match) invalidSchedule('data local invalida; use YYYY-MM-DD');
const year = Number(match[1]);
const month = Number(match[2]) - 1;
const day = Number(match[3]);
const check = new Date(year, month, day, 12);
if (check.getFullYear() !== year || check.getMonth() !== month || check.getDate() !== day) {
invalidSchedule('data local invalida');
}
return [year, month, day];
}
function localTime(year, month, day, value) {
const match = /^(\d{2}):(\d{2})$/.exec(String(value || ''));
if (!match) invalidSchedule('janela de horario invalida');
const hour = Number(match[1]);
const minute = Number(match[2]);
if (hour > 23 || minute > 59) invalidSchedule('janela de horario invalida');
const result = new Date(year, month, day, hour, minute, 0, 0);
if (result.getFullYear() !== year || result.getMonth() !== month || result.getDate() !== day
|| result.getHours() !== hour || result.getMinutes() !== minute) {
invalidSchedule('janela de horario invalida para a data local');
}
return result;
}
function parseWindows(parts, windows) {
if (!Array.isArray(windows) || windows.length === 0) invalidSchedule('janela de horario obrigatoria');
const parsed = windows.map((window) => {
const start = localTime(...parts, window?.start);
const end = localTime(...parts, window?.end);
if (end < start) invalidSchedule('janela de horario invalida');
return { start, end };
}).sort((left, right) => left.start - right.start);
for (let index = 1; index < parsed.length; index += 1) {
if (parsed[index].start <= parsed[index - 1].end) invalidSchedule('janelas de horario sobrepostas');
}
return parsed;
}
export function generateDailySchedule({
date,
count,
windows,
minMs,
maxMs,
hourlyLimit,
random = Math.random
}) {
const parts = localDateParts(date);
const day = new Date(...parts, 12).getDay();
if (day === 0 || day === 6) return [];
if (!Number.isSafeInteger(count) || count < 0) invalidSchedule('quantidade invalida');
if (!Number.isFinite(minMs) || minMs <= 0 || !Number.isFinite(maxMs) || maxMs < minMs) {
invalidSchedule('intervalo invalido');
}
if (!Number.isSafeInteger(hourlyLimit) || hourlyLimit < 1) invalidSchedule('limite por hora invalido');
if (typeof random !== 'function') invalidSchedule('random invalido');
const parsedWindows = parseWindows(parts, windows);
const requested = Math.min(count, MAX_DAILY_SLOTS);
const slots = [];
let previous = null;
for (const window of parsedWindows) {
let candidate = previous === null || previous < window.start ? window.start.getTime() : previous;
while (slots.length < requested) {
if (previous !== null && candidate <= previous) {
const value = random();
if (!Number.isFinite(value) || value < 0 || value > 1) invalidSchedule('random deve retornar valor entre 0 e 1');
candidate = previous + minMs + Math.floor((maxMs - minMs) * value);
}
if (slots.length >= hourlyLimit) {
candidate = Math.max(candidate, slots[slots.length - hourlyLimit].getTime() + HOUR_MS);
}
if (candidate > window.end.getTime()) break;
slots.push(new Date(candidate));
previous = candidate;
candidate = previous;
}
if (slots.length >= requested) break;
previous = slots.length ? slots.at(-1).getTime() : null;
}
return slots;
}
+163
View File
@@ -0,0 +1,163 @@
import { transaction } from '../db.mjs';
import { equivalentPhoneNumbers, normalizePhone } from '../phone.mjs';
import { CAMPAIGN_DEFINITIONS, CAMPAIGN_STAGES } from './campaign-catalog.mjs';
import { evaluateCampaignHealth } from './campaign-metrics.mjs';
function hydrateCampaign(db, row) {
if (!row) return null;
return {
...row,
segments: JSON.parse(row.segments_json),
windows: JSON.parse(row.windows_json),
variants: db.prepare(
`SELECT id, campaign_id, code, mensagem, ativo, created_at
FROM outreach_campaign_variants
WHERE campaign_id = ?
ORDER BY code`
).all(row.id)
};
}
export function seedCampaigns(db) {
transaction(db, () => {
const insertCampaign = db.prepare(
`INSERT OR IGNORE INTO outreach_campaigns (code, name, produto, segments_json)
VALUES (?, ?, ?, ?)`
);
const findCampaign = db.prepare('SELECT id FROM outreach_campaigns WHERE code = ?');
const insertVariant = db.prepare(
`INSERT OR IGNORE INTO outreach_campaign_variants (campaign_id, code, mensagem)
VALUES (?, ?, ?)`
);
for (const definition of CAMPAIGN_DEFINITIONS) {
insertCampaign.run(
definition.code,
definition.name,
definition.produto,
JSON.stringify(definition.segments)
);
const campaignId = findCampaign.get(definition.code).id;
for (const variant of definition.variants) {
insertVariant.run(campaignId, variant.code, variant.mensagem);
}
}
});
}
export function listCampaigns(db) {
return db.prepare(
`SELECT * FROM outreach_campaigns
ORDER BY CASE code
WHEN 'gdelivery-food' THEN 1
WHEN 'gti-food' THEN 2
WHEN 'gti-retail' THEN 3
ELSE 4
END, id`
).all().map((row) => hydrateCampaign(db, row));
}
export function getCampaign(db, id) {
return hydrateCampaign(db, db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(id));
}
export function listCampaignQueue(db, campaignId, status) {
const params = [campaignId];
let statusFilter = '';
if (status !== undefined && status !== null && status !== '') {
statusFilter = ' AND queue.status = ?';
params.push(status);
}
return db.prepare(
`SELECT queue.*, variants.code AS variant_code,
leads.empresa, leads.legal_name, leads.score, leads.prioridade
FROM outreach_campaign_queue queue
JOIN outreach_campaign_variants variants ON variants.id = queue.variant_id
JOIN leads ON leads.id = queue.lead_id
WHERE queue.campaign_id = ?${statusFilter}
ORDER BY queue.id`
).all(...params);
}
export function getLeadCommercialState(db, leadId) {
return db.prepare('SELECT lead_id, stage, first_reply_at, updated_at FROM outreach_lead_states WHERE lead_id = ?')
.get(leadId) || null;
}
export function setLeadCommercialStage(db, { leadId, campaignId = null, stage, note = null }) {
if (!CAMPAIGN_STAGES.includes(stage)) throw new Error('etapa comercial invalida');
return transaction(db, () => {
const lead = db.prepare('SELECT whatsapp FROM leads WHERE id = ?').get(leadId);
if (!lead) throw new Error('lead nao encontrado');
db.prepare(
`UPDATE outreach_campaign_queue
SET status = 'cancelled', cancel_reason = 'lead_stage_moved', updated_at = datetime('now')
WHERE lead_id = ?
AND status IN ('preview', 'approved', 'scheduled')`
).run(leadId);
db.prepare(
`INSERT INTO outreach_lead_states (lead_id, stage, first_reply_at)
VALUES (?, ?, CASE WHEN ? = 'respondeu' THEN datetime('now') END)
ON CONFLICT(lead_id) DO UPDATE SET
stage = excluded.stage,
first_reply_at = CASE
WHEN excluded.stage = 'respondeu' THEN COALESCE(outreach_lead_states.first_reply_at, datetime('now'))
ELSE outreach_lead_states.first_reply_at
END,
updated_at = datetime('now')`
).run(leadId, stage, stage);
db.prepare(
'INSERT INTO outreach_stage_events (lead_id, campaign_id, stage, note) VALUES (?, ?, ?, ?)'
).run(leadId, campaignId, stage, note);
if (campaignId !== null) {
evaluateCampaignHealth(db, campaignId);
}
if (stage === 'opt_out' && lead.whatsapp) {
const numbers = equivalentPhoneNumbers(lead.whatsapp);
if (numbers.length) {
const placeholders = numbers.map(() => '?').join(', ');
db.prepare(`DELETE FROM outreach_opt_outs WHERE numero IN (${placeholders})`).run(...numbers);
db.prepare('INSERT INTO outreach_opt_outs (numero, motivo) VALUES (?, ?)')
.run(normalizePhone(lead.whatsapp), String(note || '').trim());
}
}
return getLeadCommercialState(db, leadId);
});
}
export function saveAppointment(db, input) {
const { leadId, campaignId = null, kind, participantName = null, companyName = null, preferredDay = null, preferredTime = null } = input;
if (!leadId) throw new Error('lead_id obrigatorio');
if (!kind || !['demonstracao', 'teste_g_delivery'].includes(kind)) throw new Error('tipo de agendamento invalido');
if (kind === 'demonstracao' && (!participantName || !companyName || !preferredDay || !preferredTime)) {
throw new Error('demonstracao_agendada requer participante, empresa, dia e horario');
}
if (preferredDay !== null && !/^\d{4}-\d{2}-\d{2}$/.test(preferredDay)) {
throw new Error('formato de data invalido');
}
if (preferredTime !== null && !/^\d{2}:\d{2}$/.test(preferredTime)) {
throw new Error('formato de hora invalido');
}
return transaction(db, () => {
const inserted = db.prepare(
`INSERT INTO outreach_appointments (lead_id, campaign_id, kind, participant_name, company_name, preferred_day, preferred_time)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(leadId, campaignId, kind, participantName, companyName, preferredDay, preferredTime);
return db.prepare('SELECT * FROM outreach_appointments WHERE id = ?').get(Number(inserted.lastInsertRowid));
});
}
export function listPendingReplies(db) {
return db.prepare(
`SELECT state.lead_id, state.stage, state.first_reply_at, state.updated_at,
leads.empresa, leads.whatsapp, campaigns.produto,
events.note, events.campaign_id, events.created_at AS event_created_at
FROM outreach_lead_states state
JOIN leads ON leads.id = state.lead_id
LEFT JOIN outreach_stage_events events ON events.lead_id = state.lead_id
AND events.id = (SELECT MAX(e2.id) FROM outreach_stage_events e2 WHERE e2.lead_id = state.lead_id)
LEFT JOIN outreach_campaigns campaigns ON campaigns.id = events.campaign_id
WHERE state.stage = 'respondeu'
ORDER BY state.updated_at DESC`
).all();
}
+309
View File
@@ -0,0 +1,309 @@
import {
equivalentPhoneNumbers,
normalizePhone
} from '../phone.mjs';
import { setSetting } from '../db.mjs';
import { evaluateCampaignHealth } from './campaign-metrics.mjs';
const ACTIVE_SEND_STATUSES = "'pendente','aceito','enviado','entregue','lido','desconhecido'";
const CAMPAIGN_ATTEMPT_STATUSES = `${ACTIVE_SEND_STATUSES},'erro'`;
function conflict(message, statusCode = 409) {
const error = new Error(message);
error.statusCode = statusCode;
return error;
}
function permanentPolicyConflict(message, cancelReason) {
const error = conflict(message);
error.permanentCampaignPolicy = true;
error.cancelReason = cancelReason;
return error;
}
function immediateTransaction(db, operation) {
db.exec('BEGIN IMMEDIATE');
try {
const result = operation();
db.exec('COMMIT');
return result;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
function placeholders(values) {
return values.map(() => '?').join(', ');
}
function cancelScheduledQueue(db, { queueId, reason, nowValue }) {
return immediateTransaction(db, () => db.prepare(
`UPDATE outreach_campaign_queue
SET status = 'cancelled', cancel_reason = ?, updated_at = ?
WHERE id = ? AND status = 'scheduled'`
).run(reason, nowValue, queueId).changes === 1);
}
function requireConfirmedNumber(db, numero) {
const numbers = equivalentPhoneNumbers(numero);
const row = numbers.length
? db.prepare(
`SELECT status
FROM whatsapp_number_verifications
WHERE numero IN (${placeholders(numbers)})
ORDER BY checked_at DESC, rowid DESC
LIMIT 1`
).get(...numbers)
: null;
if (row?.status !== 'confirmado') {
throw permanentPolicyConflict('numero sem confirmacao vigente no WhatsApp', 'whatsapp_not_confirmed');
}
return numbers;
}
export function reserveCampaignQueueSend(db, { queueId, now = new Date() }) {
const nowValue = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
const result = immediateTransaction(db, () => {
const row = db.prepare(
`SELECT queue.*, campaigns.status AS campaign_status,
campaigns.approved_daily_limit, campaigns.hourly_limit
FROM outreach_campaign_queue queue
JOIN outreach_campaigns campaigns ON campaigns.id = queue.campaign_id
WHERE queue.id = ?`
).get(queueId);
if (!row) throw conflict('item da fila nao encontrado', 404);
if (row.campaign_status !== 'running') throw conflict('campanha nao esta em execucao');
if (row.status !== 'scheduled') throw conflict('item da fila nao esta agendado');
if (!row.scheduled_at || new Date(row.scheduled_at).getTime() > new Date(nowValue).getTime()) {
throw conflict('item da fila ainda nao esta vencido');
}
let numbers;
try {
numbers = requireConfirmedNumber(db, row.numero);
if (db.prepare(
`SELECT 1 FROM outreach_opt_outs WHERE numero IN (${placeholders(numbers)}) LIMIT 1`
).get(...numbers)) {
throw permanentPolicyConflict('numero bloqueado por opt-out', 'opt_out');
}
if (db.prepare(
`SELECT 1
FROM outreach_inbound_replies
WHERE lead_id = ? OR numero IN (${placeholders(numbers)})
LIMIT 1`
).get(row.lead_id, ...numbers)) {
throw permanentPolicyConflict('lead possui resposta recebida', 'inbound_reply');
}
if (db.prepare(
`SELECT 1 FROM outreach_sends
WHERE lead_id = ? AND produto = ?
AND status IN (${ACTIVE_SEND_STATUSES})
LIMIT 1`
).get(row.lead_id, row.produto)) {
throw permanentPolicyConflict('envio ativo para o mesmo lead e produto', 'duplicate_product_send');
}
} catch (error) {
if (!error.permanentCampaignPolicy) throw error;
const cancelled = db.prepare(
`UPDATE outreach_campaign_queue
SET status = 'cancelled', cancel_reason = ?, updated_at = ?
WHERE id = ? AND status = 'scheduled'`
).run(error.cancelReason, nowValue, row.id).changes;
if (cancelled !== 1) throw conflict('item da fila deixou de estar agendado');
error.queueCancelled = true;
return { policyError: error };
}
const dailyAttempts = db.prepare(
`SELECT COUNT(DISTINCT sends.id) AS total
FROM outreach_campaign_queue queue
JOIN outreach_sends sends ON sends.id = queue.outreach_send_id
WHERE queue.campaign_id = ?
AND sends.origem = 'automatico'
AND sends.status IN (${CAMPAIGN_ATTEMPT_STATUSES})
AND date(sends.sent_at, 'localtime') = date(?, 'localtime')`
).get(row.campaign_id, nowValue).total;
if (dailyAttempts >= row.approved_daily_limit) {
throw conflict(`limite diario da campanha atingido (${dailyAttempts}/${row.approved_daily_limit})`, 429);
}
const hourlyAttempts = db.prepare(
`SELECT COUNT(DISTINCT sends.id) AS total
FROM outreach_campaign_queue queue
JOIN outreach_sends sends ON sends.id = queue.outreach_send_id
WHERE queue.campaign_id = ?
AND sends.origem = 'automatico'
AND sends.status IN (${CAMPAIGN_ATTEMPT_STATUSES})
AND julianday(sends.sent_at) > julianday(?) - (1.0 / 24.0)
AND julianday(sends.sent_at) <= julianday(?)`
).get(row.campaign_id, nowValue, nowValue).total;
if (hourlyAttempts >= row.hourly_limit) {
throw conflict(`limite da hora rolante atingido (${hourlyAttempts}/${row.hourly_limit})`, 429);
}
const inserted = db.prepare(
`INSERT INTO outreach_sends
(lead_id, produto, numero, mensagem_enviada, status, canal, origem, sent_at)
VALUES (?, ?, ?, ?, 'pendente', 'local', 'automatico', ?)`
).run(row.lead_id, row.produto, normalizePhone(row.numero), row.rendered_message, nowValue);
const sendId = Number(inserted.lastInsertRowid);
const updated = db.prepare(
`UPDATE outreach_campaign_queue
SET status = 'reserved', outreach_send_id = ?, updated_at = ?
WHERE id = ? AND status = 'scheduled'`
).run(sendId, nowValue, row.id);
if (updated.changes !== 1) throw conflict('item da fila foi reservado por outro processo');
return { queueId: row.id, sendId };
});
if (result.policyError) throw result.policyError;
return result;
}
function finishCampaignQueueSend(db, context, { messageId = null, error = null } = {}) {
if (!context?.queueId || !context?.sendId) return false;
return immediateTransaction(db, () => {
const row = db.prepare(
`SELECT queue.status AS queue_status, sends.status AS send_status
FROM outreach_campaign_queue queue
JOIN outreach_sends sends ON sends.id = queue.outreach_send_id
WHERE queue.id = ? AND sends.id = ?`
).get(context.queueId, context.sendId);
if (!row || row.queue_status !== 'reserved' || row.send_status !== 'pendente') return false;
const unknown = error?.deliveryUnknown === true;
const sendStatus = unknown ? 'desconhecido' : error ? 'erro' : 'aceito';
const queueStatus = unknown ? 'unknown' : error ? 'error' : 'sent';
db.prepare(
`UPDATE outreach_sends
SET status = ?, erro = ?, provider_message_id = ?,
accepted_at = CASE WHEN ? = 'aceito' THEN ? ELSE accepted_at END
WHERE id = ? AND status = 'pendente'`
).run(sendStatus, error ? String(error.message || error) : null, messageId, sendStatus, new Date().toISOString(), context.sendId);
return db.prepare(
`UPDATE outreach_campaign_queue
SET status = ?, updated_at = ?
WHERE id = ? AND status = 'reserved' AND outreach_send_id = ?`
).run(queueStatus, new Date().toISOString(), context.queueId, context.sendId).changes === 1;
});
}
export async function runDueCampaignQueue({
db,
whatsapp,
now = new Date(),
signal,
log = () => {}
}) {
const summary = { tentados: 0, enviados: 0, erros: 0, pulados: 0 };
if (signal?.aborted) return summary;
if (!whatsapp || whatsapp.getStatus().state !== 'READY') {
log('outreach: WhatsApp local nao esta conectado; fila adiada');
return summary;
}
const nowValue = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
while (!signal?.aborted) {
const item = db.prepare(
`SELECT queue.id, queue.campaign_id, queue.numero, queue.rendered_message
FROM outreach_campaign_queue queue
JOIN outreach_campaigns campaigns ON campaigns.id = queue.campaign_id
WHERE queue.status = 'scheduled' AND queue.scheduled_at <= ?
AND campaigns.status = 'running'
ORDER BY queue.scheduled_at, queue.id
LIMIT 1`
).get(nowValue);
if (!item) return summary;
summary.tentados += 1;
try {
await whatsapp.sendText({
number: item.numero,
message: item.rendered_message,
externalKey: `campaign-queue-${item.id}`,
beforeSend: () => {
if (signal?.aborted) {
const error = new Error('fila de campanha cancelada');
error.name = 'AbortError';
throw error;
}
if (whatsapp.getStatus().state !== 'READY') throw conflict('WhatsApp local nao esta conectado', 503);
return reserveCampaignQueueSend(db, { queueId: item.id, now });
},
afterSend: (result, error, context) => finishCampaignQueueSend(db, context, {
messageId: result?.messageId,
error
})
});
summary.enviados += 1;
evaluateCampaignHealth(db, item.campaign_id, now instanceof Date ? now : new Date(now));
return summary;
} catch (error) {
if (signal?.aborted || error.name === 'AbortError') return summary;
if (error.permanentCampaignPolicy && error.queueCancelled) {
summary.pulados += 1;
log(`outreach: fila #${item.id} cancelada: ${error.message}`);
evaluateCampaignHealth(db, item.campaign_id, now instanceof Date ? now : new Date(now));
continue;
}
if (error.status === 'nao_cadastrado' && cancelScheduledQueue(db, {
queueId: item.id,
reason: 'whatsapp_not_confirmed',
nowValue
})) {
summary.pulados += 1;
log(`outreach: fila #${item.id} cancelada: ${error.message}`);
evaluateCampaignHealth(db, item.campaign_id, now instanceof Date ? now : new Date(now));
continue;
}
summary.erros += 1;
log(`outreach: fila #${item.id} nao enviada: ${error.message}`);
return summary;
}
}
return summary;
}
export function pauseCampaignsAfterRestart(db) {
return immediateTransaction(db, () => {
const paused = db.prepare(
`UPDATE outreach_campaigns
SET status = 'paused', pause_reason = 'server_restart', updated_at = datetime('now')
WHERE status = 'running'`
).run().changes;
setSetting(db, 'outreach.ativo', 'false');
return paused;
});
}
export function recoverReservedCampaignQueue(db) {
return immediateTransaction(db, () => {
const rows = db.prepare(
`SELECT queue.id, queue.outreach_send_id, sends.status AS send_status
FROM outreach_campaign_queue queue
LEFT JOIN outreach_sends sends ON sends.id = queue.outreach_send_id
WHERE queue.outreach_send_id IS NOT NULL
AND queue.status IN ('reserved','sent','unknown','error')`
).all();
let recovered = 0;
for (const row of rows) {
let queueStatus;
if (['aceito', 'enviado', 'entregue', 'lido'].includes(row.send_status)) queueStatus = 'sent';
else if (row.send_status === 'erro') queueStatus = 'error';
else queueStatus = 'unknown';
if (['pendente', 'aceito'].includes(row.send_status)) {
db.prepare(
`UPDATE outreach_sends
SET status = 'desconhecido',
erro = COALESCE(NULLIF(erro, ''), 'processo encerrou sem confirmacao de entrega')
WHERE id = ?`
).run(row.outreach_send_id);
queueStatus = 'unknown';
}
recovered += db.prepare(
`UPDATE outreach_campaign_queue
SET status = ?, updated_at = datetime('now')
WHERE id = ? AND status <> ?`
).run(queueStatus, row.id, queueStatus).changes;
}
return recovered;
});
}
+70
View File
@@ -0,0 +1,70 @@
import { equivalentPhoneNumbers, normalizePhone } from '../phone.mjs';
function placeholders(values) {
return values.map(() => '?').join(', ');
}
export function recordInboundReply(db, event) {
const messageId = String(event?.messageId || '').trim();
const number = normalizePhone(event?.number);
const receivedAt = new Date(event?.receivedAt);
if (!messageId) throw new Error('messageId inbound obrigatorio');
if (!number) throw new Error('numero inbound obrigatorio');
if (!Number.isFinite(receivedAt.getTime())) throw new Error('receivedAt inbound invalido');
const receivedAtValue = receivedAt.toISOString();
const numbers = equivalentPhoneNumbers(number);
db.exec('BEGIN IMMEDIATE');
try {
const existing = db.prepare(
'SELECT lead_id FROM outreach_inbound_replies WHERE provider_message_id = ?'
).get(messageId);
if (existing) {
db.exec('COMMIT');
return { inserted: false, leadId: existing.lead_id ?? null, cancelled: 0 };
}
const latest = db.prepare(
`SELECT sends.lead_id, queue.campaign_id
FROM outreach_sends sends
JOIN outreach_campaign_queue queue ON queue.outreach_send_id = sends.id
WHERE sends.numero IN (${placeholders(numbers)})
ORDER BY julianday(sends.sent_at) DESC, sends.id DESC
LIMIT 1`
).get(...numbers);
const leadId = latest?.lead_id ?? null;
db.prepare(
`INSERT INTO outreach_inbound_replies
(provider_message_id, lead_id, numero, received_at)
VALUES (?, ?, ?, ?)`
).run(messageId, leadId, number, receivedAtValue);
if (leadId !== null) {
db.prepare(
`INSERT INTO outreach_lead_states (lead_id, stage, first_reply_at, updated_at)
VALUES (?, 'respondeu', ?, ?)
ON CONFLICT(lead_id) DO UPDATE SET
stage = 'respondeu',
first_reply_at = COALESCE(outreach_lead_states.first_reply_at, excluded.first_reply_at),
updated_at = excluded.updated_at`
).run(leadId, receivedAtValue, receivedAtValue);
db.prepare(
`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage, created_at)
VALUES (?, ?, 'respondeu', ?)`
).run(leadId, latest.campaign_id, receivedAtValue);
}
const cancelled = db.prepare(
`UPDATE outreach_campaign_queue
SET status = 'cancelled', cancel_reason = 'inbound_reply', updated_at = ?
WHERE numero IN (${placeholders(numbers)})
AND status IN ('preview', 'approved', 'scheduled')`
).run(receivedAtValue, ...numbers).changes;
db.exec('COMMIT');
return { inserted: true, leadId, cancelled };
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { runDueCampaignQueue } from './campaign-worker.mjs';
export async function runOutreachBatch({
db,
whatsapp,
log = () => {},
now = new Date(),
signal
}) {
return runDueCampaignQueue({ db, whatsapp, log, now, signal });
}
+52
View File
@@ -0,0 +1,52 @@
import { runOutreachBatch } from './run.mjs';
const CICLO_MS = 60 * 1000;
// Timer interno no processo do servidor: nao depende de agendador do sistema
// operacional (Task Scheduler/cron), entao funciona identico local (Windows)
// ou dentro de um container Docker apos a migracao — so precisa do servidor
// estar de pe, o que ja e verdade hoje. runOutreachBatch e idempotente e sai
// cedo quando desligado/fora de horario/limite batido, entao rodar a cada
// minuto e seguro; o running flag abaixo impede sobreposicao de ciclos.
export function startOutreachScheduler({
db,
whatsapp,
log = () => {},
runBatch = runOutreachBatch,
setTimeoutFn = setTimeout,
setIntervalFn = setInterval,
clearTimeoutFn = clearTimeout,
clearIntervalFn = clearInterval
}) {
let activePromise = null;
let stopped = false;
const controller = new AbortController();
const ciclo = async () => {
if (stopped || activePromise) return activePromise;
activePromise = (async () => {
try {
await runBatch({ db, whatsapp, log, signal: controller.signal });
} catch (error) {
if (!controller.signal.aborted) log(`outreach: erro inesperado no ciclo automatico: ${error.message}`);
} finally {
activePromise = null;
}
})();
return activePromise;
};
// roda um primeiro ciclo pouco depois do boot (idempotente/seguro), sem
// esperar os 20 min inteiros para o primeiro lote do dia.
const primeiro = setTimeoutFn(ciclo, 30 * 1000);
primeiro.unref();
const timer = setIntervalFn(ciclo, CICLO_MS);
timer.unref();
return async () => {
stopped = true;
clearTimeoutFn(primeiro);
clearIntervalFn(timer);
controller.abort();
await activePromise;
};
}
+54
View File
@@ -0,0 +1,54 @@
export const COMMERCIAL_VARIABLES = [
{ key: 'empresa', label: 'Nome da empresa' },
{ key: 'produto', label: 'Produto recomendado' },
{ key: 'cidade', label: 'Cidade' },
{ key: 'uf', label: 'UF' },
{ key: 'segmento', label: 'Nicho/segmento' },
{ key: 'cnpj', label: 'CNPJ' },
{ key: 'email', label: 'E-mail' },
{ key: 'whatsapp', label: 'WhatsApp/telefone' }
];
export function commercialVariableValues(lead = {}) {
const empresa = lead.empresa || lead.businessName || lead.name || 'sua empresa';
const produto = lead.produto || lead.produto_principal || lead.interesse || 'Atendimento comercial';
const cidade = lead.cidade || 'sua regiao';
const segmento = lead.segmento_label || lead.segmento || '';
const phoneNumber = lead.whatsapp || lead.phoneNumber || '';
const firstName = String(lead.name || empresa).split(/\s+/).filter(Boolean)[0] || empresa;
return {
name: lead.name || empresa,
empresa,
businessName: empresa,
firstName,
lastName: lead.lastName || '',
produto,
protocol: lead.protocol || produto,
cidade,
uf: lead.uf || '',
segmento,
cnpj: lead.cnpj || '',
email: lead.email || '',
phoneNumber,
whatsapp: phoneNumber,
kanban: lead.kanban || produto,
user: lead.user || 'Atendimento',
userEmail: lead.userEmail || '',
greeting: 'Ola'
};
}
// Substituicao simples de variaveis no texto do template. Cada mensagem ja e
// resolvida por completo antes de chamar o WhatsApp local.
export function renderTemplate(mensagem, lead) {
const values = commercialVariableValues(lead);
let output = String(mensagem || '')
.replaceAll('{empresa}', values.empresa)
.replaceAll('{cidade}', values.cidade)
.replaceAll('{produto}', values.produto)
.replaceAll('{segmento}', values.segmento);
for (const [key, value] of Object.entries(values)) {
output = output.replaceAll(`{{${key}}}`, value);
}
return output;
}
+14
View File
@@ -0,0 +1,14 @@
export function normalizePhone(value = '') {
return String(value).replace(/\D/g, '');
}
export function equivalentPhoneNumbers(value = '') {
const digits = normalizePhone(value);
const numbers = digits ? [digits] : [];
if (!digits.startsWith('55')) return numbers;
const prefix = digits.slice(0, 4);
const local = digits.slice(4);
if (local.length === 9 && local.startsWith('9')) numbers.push(`${prefix}${local.slice(1)}`);
if (local.length === 8 && /^[6-9]/.test(local)) numbers.push(`${prefix}9${local}`);
return [...new Set(numbers)];
}
+153
View File
@@ -0,0 +1,153 @@
import { cleanText } from './lead-engine.mjs';
import { normalizePhone } from './phone.mjs';
// Regras de validacao, score e deduplicacao do tratamento de leads.
//
// Dois indicadores separados:
// Score Qualidade (0-100, "os dados sao confiaveis?"): +30 WhatsApp valido,
// +20 CNPJ valido, +20 empresa valida, +5 cidade, +10 segmento, +10 CNAE,
// +5 website.
// Score Comercial (0-100, "qual a chance de comprar?"): aderencia do nicho
// (alta 40 / media 30 / baixa 20) + 15 website + 10 e-mail + 15 WhatsApp
// valido + 20 CNPJ valido.
// Score Geral = round(Comercial*0.6 + Qualidade*0.4) — usado pra ordenar a tela.
// Classificacao (por Score Geral): 90-100 excelente, 75-89 bom, 55-74 medio,
// 35-54 baixo, 0-34 muito_baixo. Prioridade comercial deriva 1:1 dela.
// Duplicidade: CNPJ valido > WhatsApp valido > empresa + cidade.
export function onlyDigits(value = '') {
return normalizePhone(value);
}
export function validatePhone(value = '') {
const digits = onlyDigits(value);
if (!digits) return { valid: false, motivo: 'telefone vazio' };
if (!digits.startsWith('55') || digits.length < 12 || digits.length > 13) {
return { valid: false, motivo: 'telefone invalido' };
}
const ddd = Number(digits.slice(2, 4));
if (ddd < 11 || ddd > 99) return { valid: false, motivo: 'ddd invalido' };
const local = digits.slice(4);
if (/^(\d)\1+$/.test(local)) return { valid: false, motivo: 'telefone invalido' };
return { valid: true, motivo: 'telefone valido' };
}
export function validateCnpj(value = '') {
const cnpj = onlyDigits(value);
if (cnpj.length !== 14 || /^(\d)\1+$/.test(cnpj)) return false;
const digit = (base) => {
let weight = base.length - 7;
let sum = 0;
for (const char of base) {
sum += Number(char) * weight;
weight -= 1;
if (weight < 2) weight = 9;
}
const rest = sum % 11;
return rest < 2 ? 0 : 11 - rest;
};
return digit(cnpj.slice(0, 12)) === Number(cnpj[12])
&& digit(cnpj.slice(0, 13)) === Number(cnpj[13]);
}
export function validateEmpresa(nome = '') {
const texto = String(nome ?? '').trim();
const limpo = cleanText(texto);
if (!limpo || limpo.length < 2) {
return { valid: false, motivo: 'empresa vazia ou com lixo' };
}
if (/^\d/.test(texto)) {
return { valid: false, motivo: 'empresa comeca com numero' };
}
if (!/[a-z]/.test(limpo)) {
return { valid: false, motivo: 'empresa vazia ou com lixo' };
}
return { valid: true, motivo: 'empresa valida' };
}
// "Os dados deste lead sao confiaveis?" — nao mede potencial de compra.
export function scoreQualidade({ whatsapp, cnpj, empresa, cidade, segmento, cnae, website }) {
const telefone = validatePhone(whatsapp);
const cnpjValido = validateCnpj(cnpj);
const empresaCheck = validateEmpresa(empresa);
let score = 0;
if (telefone.valid) score += 30;
if (cnpjValido) score += 20;
if (empresaCheck.valid) score += 20;
if (String(cidade ?? '').trim()) score += 5;
if (String(segmento ?? '').trim()) score += 10;
if (onlyDigits(cnae)) score += 10;
if (String(website ?? '').trim()) score += 5;
return {
score,
telefoneValido: telefone.valid,
motivoTelefone: telefone.motivo,
cnpjValido,
empresaValida: empresaCheck.valid,
motivoEmpresa: empresaCheck.motivo
};
}
const AFFINITY_POINTS = { alta: 40, media: 30, baixa: 20 };
// "Qual a chance desta empresa contratar o servico oferecido?"
export function scoreComercial({ whatsapp, cnpj, website, email, affinity }) {
let score = AFFINITY_POINTS[affinity] ?? AFFINITY_POINTS.media;
if (String(website ?? '').trim()) score += 15;
if (String(email ?? '').trim()) score += 10;
if (validatePhone(whatsapp).valid) score += 15;
if (validateCnpj(cnpj)) score += 20;
return score;
}
export function scoreGeral(scoreQualidadeValor, scoreComercialValor) {
return Math.round(scoreComercialValor * 0.6 + scoreQualidadeValor * 0.4);
}
// Prioridade comercial: 1 = atacar imediatamente, 4 = so em campanhas especificas.
export const CLASSIFICACAO_TIERS = [
{ min: 90, id: 'excelente', label: 'Excelente', prioridade: 1 },
{ min: 75, id: 'bom', label: 'Bom', prioridade: 1 },
{ min: 55, id: 'medio', label: 'Médio', prioridade: 2 },
{ min: 35, id: 'baixo', label: 'Baixo', prioridade: 3 },
{ min: 0, id: 'muito_baixo', label: 'Muito Baixo', prioridade: 4 }
];
export function classificarScoreGeral(score) {
return CLASSIFICACAO_TIERS.find((tier) => score >= tier.min);
}
// Chave byte-compativel com a chave_duplicidade do TRATADOS (cnpj:...) e com o
// registro legado de envios (phone:... / name:<empresa>:<cidade>).
export function dedupKey({ cnpj, whatsapp, empresa, cidade }) {
const cnpjDigits = onlyDigits(cnpj);
if (validateCnpj(cnpjDigits)) return `cnpj:${cnpjDigits}`;
const phoneDigits = onlyDigits(whatsapp);
if (validatePhone(phoneDigits).valid) return `phone:${phoneDigits}`;
return `name:${cleanText(empresa)}:${cleanText(cidade)}`;
}
export function extractDdd(whatsapp = '') {
const digits = onlyDigits(whatsapp);
return digits.startsWith('55') && digits.length >= 12 ? digits.slice(2, 4) : '';
}
// 'lojas_de_calcados' -> 'Lojas De Calcados' (mesmo formato do segmento_legivel legado)
export function segmentLabel(name = '') {
return String(name)
.split('_')
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
+142
View File
@@ -0,0 +1,142 @@
import http from 'node:http';
import { paths } from '../env.mjs';
import { createRouter } from './router.mjs';
import { serveStatic } from './static.mjs';
import { openSse } from './sse.mjs';
import { registerMetaRoutes } from './routes/meta.mjs';
import { registerCaptureRoutes } from './routes/capture.mjs';
import { registerExtractionRoutes } from './routes/extractions.mjs';
import { registerLeadRoutes } from './routes/leads.mjs';
import { registerExportRoutes } from './routes/exports.mjs';
import { registerAuthRoutes } from './routes/auth.mjs';
import { registerOutreachRoutes } from './routes/outreach.mjs';
import { registerWhatsAppRoutes } from './routes/whatsapp.mjs';
import { registerCampaignRoutes } from './routes/campaigns.mjs';
import { getCookie, getSession, SESSION_COOKIE } from './session.mjs';
import { seedCampaigns } from '../outreach/campaign-store.mjs';
const PUBLIC_API_PATHS = new Set(['/api/auth/login', '/api/health']);
const BODY_LIMIT = 1024 * 1024; // 1MB
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (chunk) => {
size += chunk.length;
if (size > BODY_LIMIT) {
reject(new Error('corpo da requisicao muito grande'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (!chunks.length) {
resolve(null);
return;
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch {
reject(new Error('JSON invalido no corpo da requisicao'));
}
});
req.on('error', reject);
});
}
export function createAppServer({ db, jobs, whatsapp, log = () => {} }) {
seedCampaigns(db);
const router = createRouter();
const deps = { db, jobs, whatsapp };
registerAuthRoutes(router, deps);
registerMetaRoutes(router, deps);
registerCaptureRoutes(router, deps);
registerExtractionRoutes(router, deps);
registerLeadRoutes(router, deps);
registerExportRoutes(router, deps);
registerOutreachRoutes(router, deps);
registerWhatsAppRoutes(router, deps);
registerCampaignRoutes(router, deps);
// SSE de progresso dos jobs (rota especial: gerencia a resposta manualmente)
router.add('GET', '/api/jobs/:jobId/events', async (req, res, { params }) => {
const sse = openSse(res);
const unsubscribe = jobs.subscribe(params.jobId, (event) => {
const name = event.t === 'done' ? 'done' : event.t === 'error' ? 'error' : event.t;
sse.send(name, event);
});
if (unsubscribe === null) {
sse.send('error', { t: 'error', message: 'job nao encontrado (o servidor pode ter sido reiniciado)' });
sse.close();
return;
}
req.on('close', () => {
unsubscribe();
sse.close();
});
});
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
const pathname = url.pathname;
const json = (status, data) => {
const headers = {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store'
};
if (pathname === '/api/whatsapp/qr') {
headers.pragma = 'no-cache';
headers.expires = '0';
}
res.writeHead(status, headers);
res.end(JSON.stringify(data));
};
const session = getSession(getCookie(req, SESSION_COOKIE));
const user = session?.user || null;
try {
if (pathname.startsWith('/api/') && !PUBLIC_API_PATHS.has(pathname) && !user) {
json(401, { erro: 'não autenticado' });
return;
}
const matched = router.match(req.method, pathname);
if (matched) {
let body = null;
if (req.method === 'POST' || req.method === 'PUT') {
body = await readJsonBody(req);
}
await matched.handler(req, res, { params: matched.params, query: url.searchParams, body, json, user });
return;
}
if (req.method === 'GET' && !pathname.startsWith('/api/')) {
if ((pathname === '/' || pathname === '/index.html') && !user) {
res.writeHead(302, { location: '/login.html' });
res.end();
return;
}
if (serveStatic(paths.public, pathname, res)) return;
}
json(404, { erro: `rota nao encontrada: ${req.method} ${pathname}` });
} catch (error) {
log(`erro em ${req.method} ${pathname}: ${error.stack || error.message}`);
if (!res.headersSent) {
json(500, { erro: 'erro interno do servidor' });
} else {
res.end();
}
}
});
return server;
}
+33
View File
@@ -0,0 +1,33 @@
// Roteador minimo: metodo + padrao com :parametros. Sem dependencias.
export function createRouter() {
const routes = [];
function add(method, pattern, handler) {
const parts = pattern.split('/').filter(Boolean);
routes.push({ method, parts, handler, pattern });
}
function match(method, pathname) {
const parts = pathname.split('/').filter(Boolean);
for (const route of routes) {
if (route.method !== method || route.parts.length !== parts.length) continue;
const params = {};
let ok = true;
for (let i = 0; i < parts.length; i += 1) {
const expected = route.parts[i];
if (expected.startsWith(':')) {
params[expected.slice(1)] = decodeURIComponent(parts[i]);
} else if (expected !== parts[i]) {
ok = false;
break;
}
}
if (ok) return { handler: route.handler, params };
}
return null;
}
return { add, match };
}
+92
View File
@@ -0,0 +1,92 @@
import { findUserByUsername, createUser, listUsers, updateUserPassword } from '../../db.mjs';
import { hashPassword, verifyPassword } from '../../auth.mjs';
import { createSession, destroySession, destroyUserSessions, getCookie, sessionCookieHeader, clearSessionCookieHeader, SESSION_COOKIE } from '../session.mjs';
function publicUser(user) {
return { id: user.id, username: user.username, role: user.role };
}
export function registerAuthRoutes(router, { db }) {
router.add('POST', '/api/auth/login', async (req, res, { json, body }) => {
const username = String(body?.username || '').trim();
const password = String(body?.password || '');
const user = username ? findUserByUsername(db, username) : null;
if (!user || !verifyPassword(password, user.password_hash)) {
json(401, { erro: 'usuário ou senha inválidos' });
return;
}
const token = createSession(publicUser(user));
res.setHeader('Set-Cookie', sessionCookieHeader(token));
json(200, { user: publicUser(user) });
});
router.add('POST', '/api/auth/logout', async (req, res, { json }) => {
const token = getCookie(req, SESSION_COOKIE);
if (token) destroySession(token);
res.setHeader('Set-Cookie', clearSessionCookieHeader());
json(200, { ok: true });
});
router.add('GET', '/api/auth/me', async (req, res, { json, user }) => {
if (!user) {
json(401, { erro: 'não autenticado' });
return;
}
json(200, { user });
});
router.add('PUT', '/api/auth/password', async (req, res, { json, user, body }) => {
const current = user ? findUserByUsername(db, user.username) : null;
const currentPassword = String(body?.senhaAtual || '');
const newPassword = String(body?.novaSenha || '');
if (!current || !verifyPassword(currentPassword, current.password_hash)) {
json(403, { erro: 'senha atual incorreta' });
return;
}
if (newPassword.length < 8) {
json(400, { erro: 'nova senha deve ter ao menos 8 caracteres' });
return;
}
updateUserPassword(db, current.id, hashPassword(newPassword));
destroyUserSessions(current);
res?.setHeader?.('Set-Cookie', clearSessionCookieHeader());
json(200, { ok: true, relogin: true });
});
router.add('GET', '/api/users', async (req, res, { json, user }) => {
if (user?.role !== 'admin') {
json(403, { erro: 'apenas administradores podem ver os usuários' });
return;
}
json(200, { users: listUsers(db) });
});
router.add('POST', '/api/users', async (req, res, { json, user, body }) => {
if (user?.role !== 'admin') {
json(403, { erro: 'apenas administradores podem criar usuários' });
return;
}
const username = String(body?.username || '').trim();
const password = String(body?.password || '');
const role = body?.role === 'admin' ? 'admin' : 'comercial';
if (username.length < 3) {
json(400, { erro: 'usuário deve ter ao menos 3 caracteres' });
return;
}
if (password.length < 8) {
json(400, { erro: 'senha deve ter ao menos 8 caracteres' });
return;
}
if (findUserByUsername(db, username)) {
json(409, { erro: 'já existe um usuário com esse nome' });
return;
}
createUser(db, { username, passwordHash: hashPassword(password), role });
json(201, { ok: true });
});
}
+389
View File
@@ -0,0 +1,389 @@
import { countDailyOutreachUsage, getSetting, transaction } from '../../db.mjs';
import { prepareDailyQueue } from '../../outreach/campaign-queue.mjs';
import {
CampaignScheduleValidationError,
generateDailySchedule
} from '../../outreach/campaign-schedule.mjs';
import { campaignMetrics } from '../../outreach/campaign-metrics.mjs';
import { getCampaign, listCampaignQueue, listCampaigns, listPendingReplies, saveAppointment, setLeadCommercialStage } from '../../outreach/campaign-store.mjs';
const CAMPAIGN_ATTEMPT_STATUSES = "'pendente','aceito','enviado','entregue','lido','desconhecido','erro'";
const CAMPAIGN_DAILY_QUEUE_STATUSES = "'approved','scheduled','reserved','sent','unknown','error'";
const WEEK_LIMITS = new Map([[1, 10], [2, 12], [3, 15], [4, 18]]);
class CampaignTransitionError extends Error {}
function transitionConflict(message) {
throw new CampaignTransitionError(message);
}
function respondTransitionError(json, error) {
if (error instanceof CampaignTransitionError) {
json(409, { erro: error.message });
return;
}
json(500, { erro: 'falha interna ao atualizar campanha' });
}
function requireAdmin(user, json) {
if (user?.role === 'admin') return true;
json(403, { erro: 'apenas administradores podem alterar campanhas' });
return false;
}
function parseId(value) {
const id = Number(value);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
function campaignUsage(db, campaign) {
const manualLimit = Number(getSetting(db, 'outreach.mensagensPorDia', '10')) || 0;
const manualUsed = countDailyOutreachUsage(db, { origem: 'manual' });
const automaticUsed = db.prepare(
`SELECT COUNT(DISTINCT sends.id) AS total
FROM outreach_campaign_queue queue
JOIN outreach_sends sends ON sends.id = queue.outreach_send_id
WHERE queue.campaign_id = ? AND sends.origem = 'automatico'
AND sends.status IN (${CAMPAIGN_ATTEMPT_STATUSES})
AND date(sends.sent_at, 'localtime') = date('now', 'localtime')`
).get(campaign.id).total;
return {
manual: {
limit: manualLimit,
used: manualUsed,
remaining: Math.max(0, manualLimit - manualUsed)
},
campaign: {
limit: campaign.approved_daily_limit,
used: automaticUsed,
remaining: Math.max(0, campaign.approved_daily_limit - automaticUsed)
}
};
}
function localDateKey(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function campaignDayUsage(db, campaignId, dateKey) {
return db.prepare(
`SELECT COUNT(*) AS total
FROM outreach_campaign_queue
WHERE campaign_id = ? AND status IN (${CAMPAIGN_DAILY_QUEUE_STATUSES})
AND strftime('%Y-%m-%d', scheduled_at, 'localtime') = ?`
).get(campaignId, dateKey).total;
}
function campaignWithUsage(db, campaign) {
return { ...campaign, usage: campaignUsage(db, campaign) };
}
function findCampaign(db, value, json) {
const id = parseId(value);
const campaign = id === null ? null : getCampaign(db, id);
if (!campaign) json(404, { erro: 'campanha nao encontrada' });
return campaign;
}
export function registerCampaignRoutes(router, {
db,
whatsapp,
random = Math.random,
now = () => new Date()
}) {
router.add('GET', '/api/campaigns', async (req, res, { json }) => {
json(200, { campaigns: listCampaigns(db).map((campaign) => campaignWithUsage(db, campaign)) });
});
router.add('GET', '/api/campaigns/:id', async (req, res, { params, json }) => {
const campaign = findCampaign(db, params.id, json);
if (!campaign) return;
json(200, { campaign, usage: campaignUsage(db, campaign) });
});
router.add('GET', '/api/campaigns/:id/metrics', async (req, res, { params, query, json }) => {
const campaign = findCampaign(db, params.id, json);
if (!campaign) return;
const segmento = query?.get('segmento') || undefined;
const variant = query?.get('variant') || undefined;
const metrics = campaignMetrics(db, campaign.id, { segmento, variant });
json(200, { metrics });
});
router.add('POST', '/api/campaigns/:id/prepare', async (req, res, { params, json, user }) => {
if (!requireAdmin(user, json)) return;
const campaign = findCampaign(db, params.id, json);
if (!campaign) return;
const summary = await prepareDailyQueue({ db, whatsapp, campaignId: campaign.id, now: now() });
json(200, { summary, queue: listCampaignQueue(db, campaign.id, 'preview') });
});
router.add('GET', '/api/campaigns/:id/queue', async (req, res, { params, query, json }) => {
const campaign = findCampaign(db, params.id, json);
if (!campaign) return;
json(200, { queue: listCampaignQueue(db, campaign.id, query?.get('status') || undefined) });
});
router.add('POST', '/api/campaigns/:id/approve', async (req, res, { params, body, json, user }) => {
if (!requireAdmin(user, json)) return;
const campaign = findCampaign(db, params.id, json);
if (!campaign) return;
const queueIds = body?.queueIds;
if (!Array.isArray(queueIds) || queueIds.length === 0
|| queueIds.some((id) => !Number.isSafeInteger(id) || id <= 0)
|| new Set(queueIds).size !== queueIds.length) {
json(400, { erro: 'queueIds explicitos, unicos e validos sao obrigatorios' });
return;
}
if (queueIds.length > campaign.approved_daily_limit) {
json(409, { erro: 'selecao excede o limite diario aprovado' });
return;
}
let schedule;
try {
schedule = generateDailySchedule({
date: body?.date,
count: queueIds.length,
windows: campaign.windows,
minMs: campaign.interval_min_ms,
maxMs: campaign.interval_max_ms,
hourlyLimit: campaign.hourly_limit,
random
});
} catch (error) {
if (error instanceof CampaignScheduleValidationError) {
json(400, { erro: error.message });
} else {
respondTransitionError(json, error);
}
return;
}
if (schedule.length !== queueIds.length) {
json(409, { erro: 'as janelas nao comportam todos os itens selecionados' });
return;
}
try {
transaction(db, () => {
const current = db.prepare('SELECT status, approved_daily_limit FROM outreach_campaigns WHERE id = ?').get(campaign.id);
if (!current || current.status === 'running' || current.status === 'completed') {
transitionConflict('estado da campanha nao permite aprovacao');
}
const placeholders = queueIds.map(() => '?').join(',');
const rows = db.prepare(
`SELECT id FROM outreach_campaign_queue
WHERE campaign_id = ? AND status = 'preview' AND id IN (${placeholders})`
).all(campaign.id, ...queueIds);
if (rows.length !== queueIds.length) transitionConflict('fila selecionada nao pertence integralmente a campanha');
const activeToday = campaignDayUsage(db, campaign.id, body.date);
if (activeToday + queueIds.length > current.approved_daily_limit) {
transitionConflict('fila ativa do dia excede o limite diario aprovado');
}
const scheduleRow = db.prepare(
`UPDATE outreach_campaign_queue
SET status = 'scheduled', scheduled_at = ?, cancel_reason = NULL, updated_at = datetime('now')
WHERE id = ? AND campaign_id = ? AND status = 'preview'`
);
queueIds.forEach((queueId, index) => {
const result = scheduleRow.run(schedule[index].toISOString(), queueId, campaign.id);
if (result.changes !== 1) transitionConflict('fila mudou durante a aprovacao');
});
db.prepare(
`UPDATE outreach_campaign_queue
SET status = 'cancelled', cancel_reason = 'removed_by_operator', updated_at = datetime('now')
WHERE campaign_id = ? AND status = 'preview'`
).run(campaign.id);
db.prepare(
`UPDATE outreach_campaigns
SET status = 'ready', pause_reason = NULL, updated_at = datetime('now')
WHERE id = ?`
).run(campaign.id);
});
} catch (error) {
respondTransitionError(json, error);
return;
}
json(200, { scheduled: queueIds.length, campaign: getCampaign(db, campaign.id) });
});
router.add('DELETE', '/api/campaigns/:id/queue/:queueId', async (req, res, { params, json, user }) => {
if (!requireAdmin(user, json)) return;
const campaign = findCampaign(db, params.id, json);
if (!campaign) return;
const queueId = parseId(params.queueId);
if (queueId === null) {
json(404, { erro: 'item da fila nao encontrado' });
return;
}
let result;
try {
result = transaction(db, () => {
const row = db.prepare(
'SELECT status FROM outreach_campaign_queue WHERE id = ? AND campaign_id = ?'
).get(queueId, campaign.id);
if (!row) return null;
if (!['preview', 'approved', 'scheduled'].includes(row.status)) {
transitionConflict('item reservado, enviado ou terminal nao pode ser removido');
}
db.prepare(
`UPDATE outreach_campaign_queue
SET status = 'cancelled', cancel_reason = 'removed_by_operator',
updated_at = datetime('now')
WHERE id = ? AND campaign_id = ?`
).run(queueId, campaign.id);
return { id: queueId, status: 'cancelled' };
});
} catch (error) {
respondTransitionError(json, error);
return;
}
if (!result) {
json(404, { erro: 'item da fila nao encontrado' });
return;
}
json(200, { queue: result });
});
router.add('POST', '/api/campaigns/:id/start', async (req, res, { params, json, user }) => {
if (!requireAdmin(user, json)) return;
const campaign = findCampaign(db, params.id, json);
if (!campaign) return;
if (whatsapp.getStatus().state !== 'READY') {
json(409, { erro: 'WhatsApp local nao esta READY' });
return;
}
try {
transaction(db, () => {
const current = db.prepare('SELECT status, approved_daily_limit FROM outreach_campaigns WHERE id = ?').get(campaign.id);
if (!current || !['ready', 'paused'].includes(current.status)) {
transitionConflict('campanha precisa estar ready ou paused');
}
const scheduled = db.prepare(
"SELECT 1 FROM outreach_campaign_queue WHERE campaign_id = ? AND status = 'scheduled' LIMIT 1"
).get(campaign.id);
if (!scheduled) transitionConflict('campanha precisa de ao menos um item agendado');
if (campaignDayUsage(db, campaign.id, localDateKey(now())) > current.approved_daily_limit) {
transitionConflict('fila ativa do dia excede o limite diario aprovado');
}
db.prepare(
"UPDATE outreach_campaigns SET status = 'running', pause_reason = NULL, updated_at = datetime('now') WHERE id = ?"
).run(campaign.id);
});
} catch (error) {
respondTransitionError(json, error);
return;
}
json(200, { campaign: getCampaign(db, campaign.id) });
});
router.add('POST', '/api/campaigns/:id/pause', async (req, res, { params, body, json, user }) => {
if (!requireAdmin(user, json)) return;
const campaign = findCampaign(db, params.id, json);
if (!campaign) return;
const reason = typeof body?.reason === 'string' ? body.reason.trim() : '';
if (!reason || reason.length > 500) {
json(400, { erro: 'motivo da pausa e obrigatorio e deve ter ate 500 caracteres' });
return;
}
try {
transaction(db, () => {
const current = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaign.id);
if (!current || !['ready', 'running', 'paused'].includes(current.status)) {
transitionConflict('campanha precisa estar ready, running ou paused para ser pausada');
}
db.prepare(
"UPDATE outreach_campaigns SET status = 'paused', pause_reason = ?, updated_at = datetime('now') WHERE id = ?"
).run(reason, campaign.id);
});
} catch (error) {
respondTransitionError(json, error);
return;
}
json(200, { campaign: getCampaign(db, campaign.id) });
});
router.add('GET', '/api/campaigns/replies/pending', async (req, res, { json }) => {
json(200, { pending: listPendingReplies(db) });
});
router.add('PUT', '/api/campaigns/leads/:leadId/stage', async (req, res, { params, body, json }) => {
const leadId = parseId(params.leadId);
if (!leadId || !body || !body.stage) {
json(400, { erro: 'leadId valido e stage sao obrigatorios' });
return;
}
try {
const state = setLeadCommercialStage(db, {
leadId,
campaignId: body.campaignId ? parseId(body.campaignId) : null,
stage: body.stage,
note: body.note || null
});
json(200, { state });
} catch (error) {
if (/etapa comercial invalida/.test(error.message)) {
json(400, { erro: error.message });
} else {
json(500, { erro: 'falha interna ao atualizar etapa' });
}
}
});
router.add('POST', '/api/campaigns/leads/:leadId/appointments', async (req, res, { params, body, json }) => {
const leadId = parseId(params.leadId);
if (!leadId || !body) {
json(400, { erro: 'leadId valido e corpo da requisicao sao obrigatorios' });
return;
}
try {
const appointment = saveAppointment(db, {
leadId,
campaignId: body.campaignId ? parseId(body.campaignId) : null,
kind: body.kind,
participantName: body.participantName || null,
companyName: body.companyName || null,
preferredDay: body.preferredDay || null,
preferredTime: body.preferredTime || null
});
json(201, { appointment });
} catch (error) {
if (/tipo de agendamento invalido/.test(error.message) || /requer participante/.test(error.message)) {
json(400, { erro: error.message });
} else {
json(500, { erro: 'falha interna ao salvar agendamento' });
}
}
});
router.add('PUT', '/api/campaigns/:id/week', async (req, res, { params, body, json, user }) => {
if (!requireAdmin(user, json)) return;
const campaign = findCampaign(db, params.id, json);
if (!campaign) return;
const limit = WEEK_LIMITS.get(body?.week);
if (!limit) {
json(400, { erro: 'semana deve ser explicitamente 1, 2, 3 ou 4' });
return;
}
try {
transaction(db, () => {
if (campaignDayUsage(db, campaign.id, localDateKey(now())) > limit) {
transitionConflict('fila ativa do dia excede o novo limite semanal');
}
db.prepare(
`UPDATE outreach_campaigns
SET week_number = ?, approved_daily_limit = ?, updated_at = datetime('now')
WHERE id = ?`
).run(body.week, limit, campaign.id);
});
} catch (error) {
respondTransitionError(json, error);
return;
}
json(200, { campaign: getCampaign(db, campaign.id) });
});
}
+161
View File
@@ -0,0 +1,161 @@
import fs from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { paths } from '../../env.mjs';
import { UF_NAMES } from '../../sources/common.mjs';
import { getSource } from '../../sources/index.mjs';
import { isSqliteConstraintError } from '../../db.mjs';
function hasCnpjBaseForUf(uf) {
return fs.existsSync(path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`))
|| (uf === 'BA' && fs.existsSync(path.join(paths.cnpjDir, 'estabelecimentos-bahia.csv')));
}
export function registerCaptureRoutes(router, { db, jobs }) {
router.add('POST', '/api/capture/runs', async (req, res, { json, body }) => {
const sources = Array.isArray(body?.sources) ? body.sources.map((value) => typeof value === 'string' ? value.trim() : value) : [];
const uf = String(body?.uf || '').toUpperCase();
const cities = Array.isArray(body?.cities) ? body.cities.map((value) => typeof value === 'string' ? value.trim() : value) : [];
const segments = Array.isArray(body?.segments) ? body.segments.map((value) => typeof value === 'string' ? value.trim() : value) : [];
const limits = {
maxPerQuery: Number(body?.limits?.maxPerQuery),
queryLimit: Number(body?.limits?.queryLimit),
googlePages: body?.limits?.googlePages === undefined ? 3 : Number(body.limits.googlePages)
};
const invalidArray = (values, max) => !values.length || values.length > max
|| values.some((value) => typeof value !== 'string' || !value || value.length > 100);
if (invalidArray(sources, 10) || new Set(sources).size !== sources.length) {
json(400, { erro: 'fontes invalidas: selecione de 1 a 10 fontes unicas' });
return;
}
for (const id of sources) {
if (!getSource(id)) {
json(400, { erro: `fonte desconhecida: ${id}` });
return;
}
}
if (!UF_NAMES[uf]) {
json(400, { erro: 'informe uma UF valida' });
return;
}
if (invalidArray(cities, 1000) || (cities.includes('all') && cities.length !== 1)) {
json(400, { erro: 'cidades invalidas: selecione de 1 a 1000 municipios ou somente "all"' });
return;
}
if (!cities.includes('all')) {
const findCity = db.prepare('SELECT 1 FROM municipios WHERE uf = ? AND nome = ?');
const unknown = cities.find((city) => !findCity.get(uf, city));
if (unknown) {
json(400, { erro: `municipio invalido para ${uf}: ${unknown}` });
return;
}
}
if (invalidArray(segments, 250)
|| (segments.includes('all') && segments.some((segment) => segment !== 'all' && !segment.startsWith('cnae:')))) {
json(400, { erro: 'segmentos invalidos: selecione de 1 a 250 nichos ou somente "all"' });
return;
}
const findSegment = db.prepare('SELECT 1 FROM segments WHERE name = ? AND active = 1');
const findCnae = db.prepare('SELECT 1 FROM cnaes WHERE codigo = ?');
for (const segment of segments) {
if (segment === 'all') continue;
if (/^cnae:\d{7}$/.test(segment)) {
if (findCnae.get(segment.slice(5))) continue;
} else if (findSegment.get(segment)) {
continue;
}
json(400, { erro: `segmento ou CNAE invalido: ${segment}` });
return;
}
if (!Number.isInteger(limits.maxPerQuery) || limits.maxPerQuery < 1 || limits.maxPerQuery > 100
|| !Number.isInteger(limits.queryLimit) || limits.queryLimit < 0 || limits.queryLimit > 10000
|| !Number.isInteger(limits.googlePages) || limits.googlePages < 1 || limits.googlePages > 3) {
json(400, { erro: 'limites invalidos: maxPerQuery 1..100, queryLimit 0..10000, googlePages 1..3' });
return;
}
if ((sources.includes('cnpj-base') || sources.includes('brasilapi')) && !hasCnpjBaseForUf(uf)) {
json(409, {
erro: `a base CNPJ de ${uf} ainda nao foi extraida`,
precisaExtracao: true,
uf
});
return;
}
const running = db.prepare("SELECT id FROM capture_runs WHERE status IN ('pending','running')").get();
if (running) {
json(409, { erro: `ja existe uma captura em andamento (#${running.id}). Aguarde ou cancele.` });
return;
}
const params = { sources, uf, cities, segments, limits };
let inserted;
try {
inserted = db.prepare("INSERT INTO capture_runs (params_json, status) VALUES (?, 'pending')")
.run(JSON.stringify(params));
} catch (error) {
if (!isSqliteConstraintError(error)) throw error;
json(409, { erro: 'outra captura foi iniciada simultaneamente. Aguarde ou cancele.' });
return;
}
const runId = Number(inserted.lastInsertRowid);
const ownerToken = randomUUID();
const jobId = jobs.startJob({
type: 'captura',
script: 'src/capture/capture-job.mjs',
args: [`--run-id=${runId}`],
meta: { runId, uf, sources },
ownerToken
});
db.prepare("UPDATE capture_runs SET job_id = ?, owner_token = ?, heartbeat_at = datetime('now') WHERE id = ?")
.run(jobId, ownerToken, runId);
json(202, { runId, jobId });
});
router.add('GET', '/api/capture/runs', async (req, res, { json, query }) => {
const limit = Math.min(100, Math.max(1, Number(query.get('limit')) || 20));
const rows = db.prepare(
`SELECT id, job_id, params_json, status, progress_json, raw_count, new_count, dup_count,
error, started_at, finished_at, created_at
FROM capture_runs ORDER BY id DESC LIMIT ?`
).all(limit);
json(200, {
runs: rows.map((row) => ({
...row,
params: JSON.parse(row.params_json || '{}'),
progress: JSON.parse(row.progress_json || 'null'),
params_json: undefined,
progress_json: undefined
}))
});
});
router.add('GET', '/api/capture/runs/:id', async (req, res, { json, params }) => {
const row = db.prepare('SELECT * FROM capture_runs WHERE id = ?').get(Number(params.id));
if (!row) {
json(404, { erro: 'execucao nao encontrada' });
return;
}
json(200, {
...row,
params: JSON.parse(row.params_json || '{}'),
progress: JSON.parse(row.progress_json || 'null'),
params_json: undefined,
progress_json: undefined
});
});
router.add('POST', '/api/capture/runs/:id/cancel', async (req, res, { json, params }) => {
const row = db.prepare('SELECT job_id, status FROM capture_runs WHERE id = ?').get(Number(params.id));
if (!row) {
json(404, { erro: 'execucao nao encontrada' });
return;
}
const ok = row.job_id ? jobs.cancelJob(row.job_id) : false;
json(ok ? 202 : 409, ok ? { cancelando: true } : { erro: `nao ha job ativo para cancelar (status: ${row.status})` });
});
}
+115
View File
@@ -0,0 +1,115 @@
import fs from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { paths } from '../../env.mjs';
import { countLeads } from '../../treatment/queries.mjs';
import { isSqliteConstraintError } from '../../db.mjs';
const TIPOS = new Set(['excel']);
function parseExportRow(row) {
return {
...row,
filtro: JSON.parse(row.filtro_json || '{}'),
params: JSON.parse(row.params_json || '{}'),
arquivos: JSON.parse(row.arquivos_json || '[]'),
filtro_json: undefined,
params_json: undefined,
arquivos_json: undefined
};
}
export function registerExportRoutes(router, { db, jobs }) {
router.add('POST', '/api/exports', async (req, res, { json, body }) => {
const tipo = String(body?.tipo || '');
if (!TIPOS.has(tipo)) {
json(400, { erro: `tipo invalido. Use: ${[...TIPOS].join(', ')}` });
return;
}
const filtro = body?.filtro && typeof body.filtro === 'object' ? body.filtro : {};
const requestedParams = body?.params && typeof body.params === 'object' ? body.params : {};
const params = requestedParams.marcarExportado === false ? { marcarExportado: false } : {};
const running = db.prepare("SELECT id FROM exports WHERE status IN ('pending','running')").get();
if (running) {
json(409, { erro: `ja existe uma exportacao em andamento (#${running.id})` });
return;
}
const total = countLeads(db, filtro);
if (!total) {
json(400, { erro: 'nenhum lead corresponde ao filtro selecionado' });
return;
}
let inserted;
try {
inserted = db.prepare(
"INSERT INTO exports (tipo, filtro_json, params_json, status) VALUES (?, ?, ?, 'pending')"
).run(tipo, JSON.stringify(filtro), JSON.stringify(params));
} catch (error) {
if (!isSqliteConstraintError(error)) throw error;
json(409, { erro: 'outra exportacao foi iniciada simultaneamente. Aguarde terminar.' });
return;
}
const exportId = Number(inserted.lastInsertRowid);
const ownerToken = randomUUID();
const jobId = jobs.startJob({
type: 'exportacao',
script: 'src/export/export-job.mjs',
args: [`--export-id=${exportId}`],
meta: { exportId, tipo, total },
ownerToken
});
db.prepare("UPDATE exports SET job_id = ?, owner_token = ?, heartbeat_at = datetime('now') WHERE id = ?")
.run(jobId, ownerToken, exportId);
json(202, { exportId, jobId, totalEstimado: total });
});
router.add('GET', '/api/exports', async (req, res, { json, query }) => {
const limit = Math.min(100, Math.max(1, Number(query.get('limit')) || 20));
const rows = db.prepare('SELECT * FROM exports ORDER BY id DESC LIMIT ?').all(limit);
json(200, { exports: rows.map(parseExportRow) });
});
router.add('GET', '/api/exports/:id', async (req, res, { json, params }) => {
const row = db.prepare('SELECT * FROM exports WHERE id = ?').get(Number(params.id));
if (!row) {
json(404, { erro: 'exportacao nao encontrada' });
return;
}
json(200, parseExportRow(row));
});
router.add('GET', '/api/exports/:id/download', async (req, res, { json, params, query }) => {
const row = db.prepare('SELECT * FROM exports WHERE id = ?').get(Number(params.id));
if (!row) {
json(404, { erro: 'exportacao nao encontrada' });
return;
}
const arquivos = JSON.parse(row.arquivos_json || '[]');
const nome = String(query.get('arquivo') || arquivos[0] || '');
if (!arquivos.includes(nome)) {
json(400, { erro: 'arquivo nao pertence a esta exportacao' });
return;
}
const dir = path.resolve(paths.exports, `export-${row.id}`);
const resolved = path.resolve(dir, nome);
if (!resolved.startsWith(dir + path.sep) || !fs.existsSync(resolved)) {
json(404, { erro: 'arquivo nao encontrado' });
return;
}
res.writeHead(200, {
'content-type': 'application/octet-stream',
'content-disposition': `attachment; filename="${encodeURIComponent(nome)}"`,
'content-length': fs.statSync(resolved).size
});
fs.createReadStream(resolved).pipe(res);
});
}
+77
View File
@@ -0,0 +1,77 @@
import { UF_NAMES } from '../../sources/common.mjs';
import { resolveZipReader } from '../../extract/rfb-extractor.mjs';
import { randomUUID } from 'node:crypto';
import { isSqliteConstraintError } from '../../db.mjs';
export function registerExtractionRoutes(router, { db, jobs }) {
router.add('POST', '/api/extractions', async (req, res, { json, body }) => {
const uf = String(body?.uf || '').toUpperCase();
if (!UF_NAMES[uf]) {
json(400, { erro: 'informe uma UF valida' });
return;
}
try {
resolveZipReader();
} catch (error) {
json(409, { erro: error.message });
return;
}
const running = db.prepare(
"SELECT id, uf FROM extractions WHERE status IN ('pending','running')"
).get();
if (running) {
json(409, { erro: `ja existe uma extracao em andamento (${running.uf}). Aguarde terminar.` });
return;
}
let inserted;
try {
inserted = db.prepare("INSERT INTO extractions (uf, status) VALUES (?, 'pending')").run(uf);
} catch (error) {
if (!isSqliteConstraintError(error)) throw error;
json(409, { erro: 'outra extracao foi iniciada simultaneamente. Aguarde terminar.' });
return;
}
const extractionId = Number(inserted.lastInsertRowid);
const ownerToken = randomUUID();
const jobId = jobs.startJob({
type: 'extracao',
script: 'src/extract/extract-job.mjs',
args: [`--extraction-id=${extractionId}`],
meta: { extractionId, uf },
ownerToken
});
db.prepare("UPDATE extractions SET job_id = ?, owner_token = ?, heartbeat_at = datetime('now') WHERE id = ?")
.run(jobId, ownerToken, extractionId);
json(202, { extractionId, jobId });
});
router.add('GET', '/api/extractions', async (req, res, { json }) => {
const rows = db.prepare(
`SELECT id, job_id, uf, status, csv_path, total_rows, with_phone, municipios_count,
progress_json, error, started_at, finished_at, created_at
FROM extractions ORDER BY id DESC LIMIT 20`
).all();
json(200, {
extractions: rows.map((row) => ({
...row,
progress: JSON.parse(row.progress_json || 'null'),
progress_json: undefined
}))
});
});
router.add('POST', '/api/extractions/:id/cancel', async (req, res, { json, params }) => {
const row = db.prepare('SELECT job_id, status FROM extractions WHERE id = ?').get(Number(params.id));
if (!row) {
json(404, { erro: 'extracao nao encontrada' });
return;
}
const ok = row.job_id ? jobs.cancelJob(row.job_id) : false;
json(ok ? 202 : 409, ok ? { cancelando: true } : { erro: `nao ha job ativo para cancelar (status: ${row.status})` });
});
}
+53
View File
@@ -0,0 +1,53 @@
import { listLeads, getLead, leadFacets } from '../../treatment/queries.mjs';
import {
dashboardSummary, dashboardRanking, dashboardDailyEvolution, dashboardMaiorOportunidade
} from '../../treatment/dashboard.mjs';
function filterFromQuery(query) {
const filtro = {};
for (const key of ['uf', 'cidade', 'segmento', 'classificacao', 'interesse', 'origem',
'telefone_valido', 'exportado', 'q', 'score_min', 'page', 'pageSize', 'sort',
'prioridade', 'produto', 'website', 'email']) {
const value = query.get(key);
if (value !== null && value !== '') filtro[key] = value;
}
return filtro;
}
export function registerLeadRoutes(router, { db }) {
router.add('GET', '/api/leads', async (req, res, { json, query }) => {
json(200, listLeads(db, filterFromQuery(query)));
});
router.add('GET', '/api/leads/facets', async (req, res, { json, query }) => {
json(200, leadFacets(db, filterFromQuery(query)));
});
router.add('GET', '/api/leads/:id', async (req, res, { json, params }) => {
const lead = getLead(db, Number(params.id));
if (!lead) {
json(404, { erro: 'lead nao encontrado' });
return;
}
json(200, lead);
});
router.add('GET', '/api/dashboard/summary', async (req, res, { json, query }) => {
json(200, dashboardSummary(db, filterFromQuery(query)));
});
router.add('GET', '/api/dashboard/rankings', async (req, res, { json, query }) => {
const by = query.get('by') || 'cidade';
const limit = query.get('limit') || 15;
json(200, { by, ranking: dashboardRanking(db, by, filterFromQuery(query), limit) });
});
router.add('GET', '/api/dashboard/evolution', async (req, res, { json, query }) => {
const dias = query.get('dias') || 14;
json(200, { evolution: dashboardDailyEvolution(db, filterFromQuery(query), dias) });
});
router.add('GET', '/api/dashboard/oportunidade', async (req, res, { json, query }) => {
json(200, { oportunidade: dashboardMaiorOportunidade(db, filterFromQuery(query)) });
});
}
+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 });
});
}
+223
View File
@@ -0,0 +1,223 @@
import {
addOptOut,
countDailyOutreachUsage,
getSetting,
isOptedOut,
listMessageTemplates,
listOptOuts,
removeOptOut,
reserveOutreachSend,
saveWhatsAppVerification,
finishOutreachSend,
updateMessageTemplate
} from '../../db.mjs';
import { COMMERCIAL_VARIABLES, renderTemplate } from '../../outreach/templates.mjs';
import { validatePhone, onlyDigits } from '../../scoring.mjs';
function dailyLimitStatus(db) {
const limite = Number(getSetting(db, 'outreach.mensagensPorDia', '10')) || 0;
const enviadosHoje = countDailyOutreachUsage(db, { origem: 'manual' });
return { limite, enviadosHoje, restante: Math.max(0, limite - enviadosHoje) };
}
function requireAdmin(user, json) {
if (user?.role === 'admin') return true;
json(403, { erro: 'apenas administradores podem alterar opt-outs e templates' });
return false;
}
function saveSendVerification(db, verification) {
try {
saveWhatsAppVerification(db, verification);
return null;
} catch {
return 'Nao foi possivel atualizar a verificacao local. Nao reenvie a mensagem por esse motivo.';
}
}
export function registerOutreachRoutes(router, { db, whatsapp }) {
router.add('GET', '/api/outreach/status', async (req, res, { json }) => {
const connection = whatsapp.getStatus();
const limit = dailyLimitStatus(db);
const ultimos = db.prepare(
`SELECT outreach_sends.id, outreach_sends.produto, outreach_sends.status,
outreach_sends.sent_at, outreach_sends.canal, outreach_sends.origem,
outreach_sends.numero, outreach_sends.erro, leads.empresa, leads.cidade
FROM outreach_sends LEFT JOIN leads ON leads.id = outreach_sends.lead_id
ORDER BY outreach_sends.id DESC LIMIT 20`
).all();
json(200, {
ativo: getSetting(db, 'outreach.ativo', 'false') === 'true',
configurado: connection.state === 'READY',
whatsapp: connection,
mensagensPorDia: limit.limite,
enviadosHoje: limit.enviadosHoje,
restante: limit.restante,
ultimos
});
});
router.add('POST', '/api/outreach/enviar-manual', async (req, res, { json, body }) => {
const requestedLeadId = body?.leadId;
const leadId = requestedLeadId === undefined || requestedLeadId === null || requestedLeadId === '' ? null : requestedLeadId;
if (leadId !== null && (!Number.isSafeInteger(leadId) || leadId <= 0)) {
json(404, { erro: 'lead nao encontrado' });
return;
}
const lead = leadId === null ? null : db.prepare('SELECT * FROM leads WHERE id = ?').get(leadId);
if (leadId !== null && !lead) {
json(404, { erro: 'lead nao encontrado' });
return;
}
const rawNumber = String(body?.numero || '').trim();
const messageTemplate = String(body?.mensagem || '').trim();
const validation = validatePhone(rawNumber);
if (!validation.valid) {
json(400, { erro: `numero invalido (${validation.motivo}): use DDI+DDD+numero` });
return;
}
if (!messageTemplate) {
json(400, { erro: 'mensagem obrigatoria' });
return;
}
const number = onlyDigits(rawNumber);
if (isOptedOut(db, number)) {
json(409, { ok: false, erro: 'numero bloqueado por opt-out' });
return;
}
const limit = dailyLimitStatus(db);
if (limit.restante <= 0) {
json(429, { ok: false, erro: `limite diario atingido (${limit.enviadosHoje}/${limit.limite})`, ...limit });
return;
}
if (whatsapp.getStatus().state !== 'READY') {
json(409, { ok: false, erro: 'WhatsApp local nao esta conectado' });
return;
}
const clientContext = body?.lead && typeof body.lead === 'object' ? body.lead : {};
const context = lead || clientContext;
const message = renderTemplate(messageTemplate, { ...context, whatsapp: number });
const produto = String(context.produto_principal || context.produto || '');
try {
const externalKey = `pessoal-manual-${Date.now()}`;
const result = await whatsapp.sendText({
number,
message,
externalKey,
beforeSend: () => reserveOutreachSend(db, { leadId: lead?.id || null, produto, numero: number, mensagem: message, origem: 'manual' }),
afterSend: (sendResult, error, sendId) => finishOutreachSend(db, sendId, {
messageId: sendResult?.messageId,
error,
unknown: error?.deliveryUnknown === true
})
});
const verificationWarning = saveSendVerification(db, {
...result,
numero: number,
status: 'confirmado'
});
json(202, {
ok: true,
status: 'aceito',
messageId: result.messageId,
...(result.resolvedNumber ? { resolvedNumber: result.resolvedNumber } : {}),
...(result.variant ? { variant: result.variant } : {}),
...(verificationWarning ? { aviso: verificationWarning } : {})
});
} catch (error) {
if (error.deliveryUnknown) {
const verificationWarning = saveSendVerification(db, {
...error,
numero: number,
status: 'confirmado'
});
json(202, {
ok: false,
status: 'resultado_desconhecido',
...(error.resolvedNumber ? { resolvedNumber: error.resolvedNumber } : {}),
...(error.variant ? { variant: error.variant } : {}),
aviso: [
'O envio pode ter sido aceito pelo WhatsApp, mas nao foi possivel confirmar o resultado. Nao reenvie sem confirmar no celular.',
verificationWarning
].filter(Boolean).join(' ')
});
return;
}
if (error.status === 'nao_cadastrado' || error.status === 'erro_temporario') {
const verificationStatus = error.status === 'nao_cadastrado' ? 'nao_cadastrado' : 'erro_temporario';
const verificationWarning = saveSendVerification(db, {
...error,
numero: number,
status: verificationStatus,
erro: verificationStatus === 'erro_temporario' ? error : null
});
error.verificationWarning = verificationWarning;
}
if (error.deliverySucceeded) {
json(202, {
ok: false,
status: 'persistencia_incerta',
messageId: error.messageId || null,
aviso: 'A mensagem pode ter sido aceita pelo WhatsApp, mas o registro local falhou. Nao reenvie sem confirmar no celular.'
});
return;
}
const status = error.statusCode || (error.status === 'nao_cadastrado' ? 422 : 502);
json(status, {
ok: false,
erro: status === 502 ? `falha ao enviar pelo WhatsApp local: ${error.message}` : error.message,
...(error.verificationWarning ? { aviso: error.verificationWarning } : {}),
...(error.limit || {})
});
}
});
router.add('GET', '/api/outreach/variables', async (req, res, { json }) => {
json(200, { variables: COMMERCIAL_VARIABLES });
});
router.add('GET', '/api/outreach/templates', async (req, res, { json }) => {
json(200, { templates: listMessageTemplates(db) });
});
router.add('PUT', '/api/outreach/templates/:produto', async (req, res, { json, params, body, user }) => {
if (!requireAdmin(user, json)) return;
if (!body || typeof body !== 'object') {
json(400, { erro: 'corpo JSON esperado: {"mensagem":"...","ativo":true}' });
return;
}
const updated = updateMessageTemplate(db, params.produto, { mensagem: body.mensagem, ativo: body.ativo });
if (!updated) {
json(404, { erro: 'template nao encontrado' });
return;
}
json(200, updated);
});
router.add('GET', '/api/outreach/opt-outs', async (req, res, { json }) => {
json(200, { optOuts: listOptOuts(db) });
});
router.add('POST', '/api/outreach/opt-outs', async (req, res, { json, body, user }) => {
if (!requireAdmin(user, json)) return;
const number = onlyDigits(String(body?.numero || ''));
const validation = validatePhone(number);
if (!validation.valid) {
json(400, { erro: `numero invalido (${validation.motivo})` });
return;
}
json(201, { optOut: addOptOut(db, number, body?.motivo) });
});
router.add('DELETE', '/api/outreach/opt-outs/:numero', async (req, res, { json, params, user }) => {
if (!requireAdmin(user, json)) return;
if (!removeOptOut(db, params.numero)) {
json(404, { erro: 'opt-out nao encontrado' });
return;
}
json(200, { ok: true });
});
}
+117
View File
@@ -0,0 +1,117 @@
import QRCode from 'qrcode';
import { saveWhatsAppVerification } from '../../db.mjs';
import { normalizePhone } from '../../phone.mjs';
function requireAdmin(user, json) {
if (user?.role === 'admin') return true;
json(403, { erro: 'apenas administradores podem gerenciar o WhatsApp' });
return false;
}
function saveVerificationSafely(db, verification) {
try {
return { saved: saveWhatsAppVerification(db, verification), warning: null };
} catch {
return {
saved: null,
warning: 'Nao foi possivel atualizar a verificacao local.'
};
}
}
export function registerWhatsAppRoutes(router, { db, whatsapp, toDataURL = QRCode.toDataURL }) {
router.add('GET', '/api/whatsapp/status', async (req, res, { json }) => {
json(200, whatsapp.getStatus());
});
router.add('POST', '/api/whatsapp/connect', async (req, res, { json, user }) => {
if (!requireAdmin(user, json)) return;
try {
await whatsapp.connect();
json(202, { ok: true, status: whatsapp.getStatus() });
} catch (error) {
json(503, { ok: false, erro: `nao foi possivel iniciar o WhatsApp: ${error.message}` });
}
});
router.add('POST', '/api/whatsapp/refresh-qr', async (req, res, { json, user }) => {
if (!requireAdmin(user, json)) return;
try {
await whatsapp.refreshQr();
json(202, { ok: true, status: whatsapp.getStatus() });
} catch (error) {
json(503, { ok: false, erro: `nao foi possivel gerar novo QR: ${error.message}` });
}
});
router.add('POST', '/api/whatsapp/check-number', async (req, res, { json, user, body }) => {
if (!requireAdmin(user, json)) return;
const requestedLeadId = body?.leadId;
const leadId = requestedLeadId === undefined || requestedLeadId === null || requestedLeadId === '' ? null : requestedLeadId;
if (leadId !== null && (!Number.isSafeInteger(leadId) || leadId <= 0)) {
json(404, { erro: 'lead nao encontrado' });
return;
}
const lead = leadId === null ? null : db.prepare('SELECT id FROM leads WHERE id = ?').get(leadId);
if (leadId !== null && !lead) {
json(404, { erro: 'lead nao encontrado' });
return;
}
const numero = normalizePhone(body?.numero);
try {
const result = await whatsapp.checkNumber(body?.numero);
const status = result.registered ? 'confirmado' : 'nao_cadastrado';
const verification = { ...result, numero, status };
const persistence = saveVerificationSafely(db, verification);
json(status === 'nao_cadastrado' ? 422 : 200, {
...verification,
checked_at: persistence.saved?.checked_at ?? null,
...(persistence.warning ? { aviso: persistence.warning } : {})
});
} catch (error) {
let persistence = null;
let status = null;
if (error.status === 'erro_temporario' || error.status === 'nao_cadastrado') {
status = error.status === 'nao_cadastrado' ? 'nao_cadastrado' : 'erro_temporario';
persistence = saveVerificationSafely(db, {
...error,
numero,
status,
erro: status === 'erro_temporario' ? error : null
});
}
json(error.statusCode || (error.status === 'nao_cadastrado' ? 422 : 503), {
erro: error.message,
...(status ? {
numero,
status,
checked_at: persistence.saved?.checked_at ?? null,
...(persistence.warning ? { aviso: persistence.warning } : {})
} : {})
});
}
});
router.add('GET', '/api/whatsapp/qr', async (req, res, { json, user }) => {
if (!requireAdmin(user, json)) return;
const value = whatsapp.getQr();
if (!value) {
json(409, { erro: 'QR nao esta disponivel ou expirou; solicite uma nova conexao' });
return;
}
const qr = await toDataURL(value, { errorCorrectionLevel: 'M', margin: 1 });
json(200, { qr, expiresAt: whatsapp.getStatus().qrExpiresAt });
});
router.add('POST', '/api/whatsapp/disconnect', async (req, res, { json, user }) => {
if (!requireAdmin(user, json)) return;
const status = await whatsapp.disconnect({ logout: false });
json(200, { ok: true, status });
});
router.add('POST', '/api/whatsapp/logout', async (req, res, { json, user }) => {
if (!requireAdmin(user, json)) return;
const status = await whatsapp.disconnect({ logout: true });
json(200, { ok: true, status });
});
}
+60
View File
@@ -0,0 +1,60 @@
import crypto from 'node:crypto';
// Sessoes em memoria: simples e suficiente para um app local de usuario unico
// por vez. Reiniciar o servidor derruba as sessoes (usuarios logam de novo).
export const SESSION_COOKIE = 'maquina_pessoal_session';
const SESSION_TTL_MS = 12 * 60 * 60 * 1000; // 12h, renovada a cada requisicao
const sessions = new Map();
export function createSession(user) {
const token = crypto.randomBytes(32).toString('hex');
sessions.set(token, { user, expiresAt: Date.now() + SESSION_TTL_MS });
return token;
}
export function getSession(token) {
if (!token) return null;
const session = sessions.get(token);
if (!session) return null;
if (session.expiresAt < Date.now()) {
sessions.delete(token);
return null;
}
session.expiresAt = Date.now() + SESSION_TTL_MS;
return session;
}
export function destroySession(token) {
sessions.delete(token);
}
export function destroyUserSessions(user) {
for (const [token, session] of sessions) {
if ((user.id != null && session.user?.id === user.id)
|| (user.username && session.user?.username === user.username)) {
sessions.delete(token);
}
}
}
export function getCookie(req, name) {
const header = req.headers.cookie;
if (!header) return null;
for (const part of header.split(';')) {
const separator = part.indexOf('=');
if (separator === -1) continue;
if (part.slice(0, separator).trim() === name) {
return decodeURIComponent(part.slice(separator + 1).trim());
}
}
return null;
}
export function sessionCookieHeader(token) {
return `${SESSION_COOKIE}=${token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`;
}
export function clearSessionCookieHeader() {
return `${SESSION_COOKIE}=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0`;
}
+33
View File
@@ -0,0 +1,33 @@
// Server-Sent Events: canal de progresso ao vivo dos jobs para a tela.
export function openSse(res) {
res.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-transform',
connection: 'keep-alive',
'x-accel-buffering': 'no'
});
res.write(':ok\n\n');
const keepAlive = setInterval(() => {
try {
res.write(':ping\n\n');
} catch {
clearInterval(keepAlive);
}
}, 15000);
keepAlive.unref();
return {
send(event, data) {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
},
close() {
clearInterval(keepAlive);
try {
res.end();
} catch {
// ja fechado
}
}
};
}
+39
View File
@@ -0,0 +1,39 @@
import fs from 'node:fs';
import path from 'node:path';
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2'
};
// Servidor de arquivos do frontend (pasta public/), com protecao contra
// path traversal: qualquer caminho resolvido fora da pasta e rejeitado.
export function serveStatic(publicDir, urlPath, res) {
const relative = urlPath === '/' ? 'index.html' : urlPath.replace(/^\/+/, '');
const resolved = path.resolve(publicDir, relative);
if (!resolved.startsWith(path.resolve(publicDir) + path.sep) && resolved !== path.resolve(publicDir, 'index.html')) {
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Acesso negado');
return true;
}
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
return false;
}
const ext = path.extname(resolved).toLowerCase();
res.writeHead(200, {
'content-type': MIME[ext] || 'application/octet-stream',
'cache-control': 'no-cache'
});
fs.createReadStream(resolved).pipe(res);
return true;
}
+182
View File
@@ -0,0 +1,182 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { cleanText } from '../lead-engine.mjs';
import { onlyDigits } from '../scoring.mjs';
import { streamCsvRows } from '../util/csv.mjs';
import { buildLeadFromPlace, UA_HEADERS } from './common.mjs';
import { fetchJsonWithTimeout } from '../util/fetch.mjs';
async function brasilApiCnpj(baseUrl, cnpj) {
if (!cnpj || cnpj.length !== 14) return null;
const { response, data } = await fetchJsonWithTimeout(`${baseUrl}/cnpj/v1/${cnpj}`, { headers: UA_HEADERS });
if (!response.ok) return null;
return data;
}
function mergeBrasilApi(place, data) {
if (!data) return place;
return {
...place,
name: data.nome_fantasia || data.razao_social || place.name,
legal_name: data.razao_social || place.legal_name,
phone: [data.ddd_telefone_1, data.ddd_telefone_2].filter(Boolean)[0] || place.phone,
city: data.municipio || place.city,
state: data.uf || place.state,
cnae: data.cnae_fiscal ? String(data.cnae_fiscal) : place.cnae,
address: [data.logradouro, data.numero, data.bairro].filter(Boolean).join(', ') || place.address
};
}
function normalizeHeader(header) {
return cleanText(header).replace(/\s+/g, '_');
}
function getAny(row, keys) {
for (const key of keys) {
const normalized = normalizeHeader(key);
if (row[normalized]) return row[normalized];
}
return '';
}
function cnpjMatchesSegment(row, segment, textCache) {
const cnae = onlyDigits(getAny(row, ['cnae', 'cnae_fiscal', 'cnae principal', 'cnae_principal']));
const cnaeCodes = segment._cnaeCodes;
for (const code of cnaeCodes) {
if (cnae.startsWith(code)) return true;
}
if (textCache.value === null) {
textCache.value = cleanText([
getAny(row, ['nome fantasia', 'nome_fantasia', 'fantasia']),
getAny(row, ['razao social', 'razao_social', 'nome empresarial']),
getAny(row, ['atividade principal', 'atividade_principal', 'descricao cnae', 'descricao_cnae'])
].join(' '));
}
const text = textCache.value;
if (!text) return false;
return segment._queryTerms.some((term) => text.includes(term));
}
function cnpjRowToPlace(row, segment, city, state) {
const cnpj = onlyDigits(getAny(row, ['cnpj', 'cnpj_basico', 'cnpj completo', 'cnpj_completo']));
const phone = [
getAny(row, ['ddd 1', 'ddd1', 'ddd']),
getAny(row, ['telefone 1', 'telefone1', 'telefone', 'whatsapp'])
].filter(Boolean).join('');
const name = getAny(row, ['nome fantasia', 'nome_fantasia', 'fantasia'])
|| getAny(row, ['razao social', 'razao_social', 'nome empresarial']);
if (!name) return null;
return {
name,
legal_name: getAny(row, ['razao social', 'razao_social', 'nome empresarial']),
phone,
city: getAny(row, ['municipio', 'cidade']) || city,
state: getAny(row, ['uf', 'estado']) || state,
segment: segment.name,
website: '',
cnpj,
cnae: getAny(row, ['cnae', 'cnae_fiscal', 'cnae principal', 'cnae_principal']),
address: [
getAny(row, ['logradouro']),
getAny(row, ['numero']),
getAny(row, ['bairro'])
].filter(Boolean).join(', '),
query: `cnpj-base ${segment.name} em ${city} ${state}`
};
}
// Fonte cnpj-base: le os CSVs locais extraidos da Receita Federal
// (prospeccao/cnpj/estabelecimentos-*.csv) em streaming, filtrando por
// UF + cidades e casando cada linha com o primeiro nicho compativel.
export async function* collect(params, ctx) {
const { uf, cities, segments } = params;
const allCities = !cities || !cities.length || cities.includes('*');
const citySet = allCities ? null : new Set(cities.map((c) => cleanText(c)));
const ufClean = cleanText(uf);
// Pre-processa termos por segmento uma unica vez (o loop interno roda
// milhoes de vezes na base de uma UF inteira).
const preparedSegments = segments.map((segment) => ({
...segment,
_cnaeCodes: (segment.cnaeCodes || []).map(onlyDigits).filter(Boolean),
_queryTerms: (segment.queries || []).map((term) => cleanText(term)).filter(Boolean)
}));
const exact = path.join(ctx.cnpjDir, `estabelecimentos-${String(uf).toUpperCase()}.csv`);
const legacy = path.join(ctx.cnpjDir, 'estabelecimentos-bahia.csv');
let files = [];
try {
await fs.access(exact);
files = [exact];
} catch (error) {
if (error.code !== 'ENOENT') throw error;
if (String(uf).toUpperCase() === 'BA') {
try {
await fs.access(legacy);
files = [legacy];
} catch (legacyError) {
if (legacyError.code !== 'ENOENT') throw legacyError;
}
}
}
if (!files.length) {
ctx.log(`base CNPJ de ${uf} nao encontrada em ${ctx.cnpjDir} — fonte cnpj-base pulada`);
return;
}
let batch = [];
let scanned = 0;
for (const file of files) {
ctx.log(`cnpj-base: lendo ${path.basename(file)}`);
for await (const row of streamCsvRows(file, { normalizeHeader })) {
if (ctx.isCancelled()) return;
scanned += 1;
if (scanned % 100000 === 0) {
ctx.emit({ phase: 'cnpj-base', message: `${scanned} linhas lidas`, detail: path.basename(file) });
}
const rowState = cleanText(getAny(row, ['uf', 'estado']));
if (rowState && ufClean && rowState !== ufClean) continue;
const rowCity = cleanText(getAny(row, ['municipio', 'cidade']));
if (citySet && !citySet.has(rowCity)) continue;
const textCache = { value: null };
for (const segment of preparedSegments) {
if (!cnpjMatchesSegment(row, segment, textCache)) continue;
let place = cnpjRowToPlace(row, segment, rowCity, uf);
if (place) {
if (params.enrichBrasilApi && place.cnpj) {
try {
place = mergeBrasilApi(place, await brasilApiCnpj(ctx.endpoints.brasilApiUrl, place.cnpj));
} catch {
// enriquecimento e opcional: falha de rede nao derruba a captura
}
}
const origin = params.enrichBrasilApi ? 'cnpj-base+brasilapi' : 'cnpj-base';
batch.push(buildLeadFromPlace(place, segment, place.city, uf, origin));
}
break;
}
if (batch.length >= 2000) {
yield { origin: 'cnpj-base', label: path.basename(file), leads: batch };
batch = [];
}
}
}
if (batch.length) {
yield { origin: 'cnpj-base', label: 'cnpj-base', leads: batch };
}
ctx.log(`cnpj-base: ${scanned} linhas da base local processadas`);
}
+74
View File
@@ -0,0 +1,74 @@
import { normalizePhone } from '../lead-engine.mjs';
export const UA_HEADERS = {
accept: 'application/json,text/plain,*/*',
'user-agent': 'Maquina-Comercial-Pessoal/1.0'
};
export const UF_NAMES = {
AC: 'Acre', AL: 'Alagoas', AP: 'Amapa', AM: 'Amazonas', BA: 'Bahia',
CE: 'Ceara', DF: 'Distrito Federal', ES: 'Espirito Santo', GO: 'Goias',
MA: 'Maranhao', MT: 'Mato Grosso', MS: 'Mato Grosso do Sul', MG: 'Minas Gerais',
PA: 'Para', PB: 'Paraiba', PR: 'Parana', PE: 'Pernambuco', PI: 'Piaui',
RJ: 'Rio de Janeiro', RN: 'Rio Grande do Norte', RS: 'Rio Grande do Sul',
RO: 'Rondonia', RR: 'Roraima', SC: 'Santa Catarina', SP: 'Sao Paulo',
SE: 'Sergipe', TO: 'Tocantins'
};
export const UFS = Object.keys(UF_NAMES);
export function splitCityState(cityText = '') {
const parts = String(cityText).trim().split(/\s+/);
const last = parts.at(-1);
if (last && last.length === 2 && UF_NAMES[last.toUpperCase()]) {
return { city: parts.slice(0, -1).join(' '), state: last.toUpperCase() };
}
return { city: String(cityText).trim(), state: '' };
}
// Monta o payload padrao de lead (mesmo formato consumido por processLead)
// a partir de um estabelecimento/lugar encontrado em qualquer fonte.
export function buildLeadFromPlace(place, segment, city, state, origin) {
const phone = normalizePhone(place.phone || place.formatted_phone_number || place.international_phone_number || '');
const segmentName = place.segment || segment.name;
return {
source: origin,
contact: {
name: 'Responsavel comercial',
phone
},
company: {
name: place.name,
city: place.city || city,
state: place.state || state,
segment: segmentName
},
interest: segment.interest,
pains: segment.pains,
urgency: 'media',
message: `Lead prospectado automaticamente em ${origin}. Segmento: ${segmentName}. Website: ${place.website || 'nao informado'}.`,
consent: false,
send_external_whatsapp: false,
prospecting: {
source: origin,
query: place.query,
place_id: place.place_id || '',
osm_id: place.osm_id || '',
osm_type: place.osm_type || '',
website: place.website || '',
cnpj: place.cnpj || '',
cnae: place.cnae || '',
legal_name: place.legal_name || '',
address: place.formatted_address || place.address || '',
rating: place.rating || '',
user_ratings_total: place.user_ratings_total || '',
affinity: segment.affinity || 'media',
products: segment.products || []
}
};
}
export function applyLimits(items, limit) {
return limit > 0 ? items.slice(0, limit) : items;
}
+102
View File
@@ -0,0 +1,102 @@
import { sleep } from '../util/collections.mjs';
import { buildLeadFromPlace } from './common.mjs';
import { fetchJsonWithTimeout } from '../util/fetch.mjs';
async function searchPage(apiKey, params) {
const url = new URL('https://maps.googleapis.com/maps/api/place/textsearch/json');
for (const [key, value] of Object.entries(params)) {
if (value) url.searchParams.set(key, value);
}
url.searchParams.set('key', apiKey);
url.searchParams.set('language', 'pt-BR');
url.searchParams.set('region', 'br');
const { response, data } = await fetchJsonWithTimeout(url);
if (!response.ok) throw new Error(`Google Places HTTP ${response.status}`);
if (data.status && !['OK', 'ZERO_RESULTS'].includes(data.status)) {
throw new Error(`Google Places ${data.status}: ${data.error_message || 'sem detalhes'}`);
}
return data;
}
async function search(query, apiKey, { maxPerQuery, pages }) {
const places = [];
let page = 0;
let pageToken = '';
while (page < pages && places.length < maxPerQuery) {
if (pageToken) await sleep(2500);
const data = await searchPage(apiKey, pageToken ? { pagetoken: pageToken } : { query });
// Resultados por pagina sao no maximo 20 — spread aqui e seguro.
places.push(...(data.results || []));
page += 1;
pageToken = data.next_page_token || '';
if (!pageToken) break;
}
return places.slice(0, maxPerQuery);
}
async function placeDetails(placeId, apiKey) {
const url = new URL('https://maps.googleapis.com/maps/api/place/details/json');
url.searchParams.set('place_id', placeId);
url.searchParams.set('key', apiKey);
url.searchParams.set('language', 'pt-BR');
url.searchParams.set('fields', 'place_id,name,formatted_address,formatted_phone_number,international_phone_number,website,rating,user_ratings_total');
const { response, data } = await fetchJsonWithTimeout(url);
if (!response.ok) throw new Error(`Google Details HTTP ${response.status}`);
return data.result || {};
}
// Fonte google-places: exige GOOGLE_PLACES_API_KEY (paga). Traz telefone e
// website com boa cobertura.
export async function* collect(params, ctx) {
const apiKey = process.env.GOOGLE_PLACES_API_KEY;
if (!apiKey || apiKey === 'preencher_google_maps_api_key') {
throw new Error('GOOGLE_PLACES_API_KEY ausente: configure a chave para usar a fonte Google Places');
}
const { uf, cities, segments, limits } = params;
const pages = Math.min(3, Math.max(1, limits.googlePages || 3));
let executedQueries = 0;
for (const city of cities || []) {
const label = `${city} ${uf}`;
const cityLeads = [];
for (const segment of segments) {
for (const queryTerm of segment.queries || []) {
if (ctx.isCancelled()) return;
if (limits.queryLimit > 0 && executedQueries >= limits.queryLimit) {
if (cityLeads.length) yield { origin: 'google-places', label, leads: cityLeads };
return;
}
const query = `${queryTerm} em ${label}`;
ctx.emit({ phase: 'google-places', message: query });
executedQueries += 1;
let places = [];
try {
places = await search(query, apiKey, { maxPerQuery: limits.maxPerQuery || 10, pages });
} catch (error) {
ctx.log(`google-places falhou em ${query}: ${error.message}`);
continue;
}
for (const place of places) {
try {
const details = place.place_id ? await placeDetails(place.place_id, apiKey) : {};
cityLeads.push(buildLeadFromPlace({ ...place, ...details, query }, segment, city, uf, 'google-places'));
} catch (error) {
ctx.log(`google-places falhou ao detalhar "${place.name || place.place_id}": ${error.message}`);
}
}
}
}
if (cityLeads.length) yield { origin: 'google-places', label, leads: cityLeads };
}
}
+89
View File
@@ -0,0 +1,89 @@
import * as cnpjBase from './cnpj-base.mjs';
import * as overpass from './overpass.mjs';
import * as nominatim from './nominatim.mjs';
import * as wikidata from './wikidata.mjs';
import * as googlePlaces from './google-places.mjs';
import * as mock from './mock.mjs';
// Registro central de fontes de captura. `describe()` alimenta a tela de
// captura (o front nunca conhece detalhes das fontes, so este catalogo).
export const SOURCES = [
{
id: 'cnpj-base',
label: 'Base CNPJ (Receita Federal)',
descricao: 'Base oficial local: todas as empresas ativas da UF com telefone. Fonte principal.',
gratuita: true,
recomendada: true,
collect: cnpjBase.collect
},
{
id: 'brasilapi',
label: 'Base CNPJ + BrasilAPI',
descricao: 'Mesma base CNPJ, enriquecida online pela BrasilAPI (mais lenta).',
gratuita: true,
recomendada: false,
collect: (params, ctx) => cnpjBase.collect({ ...params, enrichBrasilApi: true }, ctx)
},
{
id: 'overpass',
label: 'OpenStreetMap (Overpass)',
descricao: 'Estabelecimentos mapeados no OpenStreetMap, com telefone/site quando cadastrados.',
gratuita: true,
recomendada: true,
collect: overpass.collect
},
{
id: 'nominatim',
label: 'OpenStreetMap (Nominatim)',
descricao: 'Busca textual no OpenStreetMap por termo de nicho.',
gratuita: true,
recomendada: false,
collect: nominatim.collect
},
{
id: 'wikidata',
label: 'Wikidata',
descricao: 'Empresas registradas no Wikidata (volume baixo, util para sites).',
gratuita: true,
recomendada: false,
collect: wikidata.collect
},
{
id: 'google-places',
label: 'Google Places',
descricao: 'Google Places (paga, exige GOOGLE_PLACES_API_KEY no .env).',
gratuita: false,
recomendada: false,
needsKey: 'GOOGLE_PLACES_API_KEY',
collect: googlePlaces.collect
},
{
id: 'mock',
label: 'Teste (mock)',
descricao: '3 leads ficticios para testar o fluxo sem tocar em APIs.',
gratuita: true,
recomendada: false,
oculta: true,
collect: mock.collect
}
];
export function getSource(id) {
return SOURCES.find((source) => source.id === id) || null;
}
export function describeSources() {
return SOURCES.filter((s) => !s.oculta).map((source) => {
const keyMissing = source.needsKey
&& (!process.env[source.needsKey] || process.env[source.needsKey].startsWith('preencher'));
return {
id: source.id,
label: source.label,
descricao: source.descricao,
gratuita: source.gratuita,
recomendada: source.recomendada,
disponivel: !keyMissing,
motivo: keyMissing ? `configure ${source.needsKey} no arquivo .env` : ''
};
});
}
+49
View File
@@ -0,0 +1,49 @@
import { buildLeadFromPlace } from './common.mjs';
// Fonte mock: 3 leads ficticios para testar o fluxo completo (captura ->
// banco -> tratamento -> exportacao) sem tocar em nenhuma API externa.
const MOCK_PLACES = [
{
name: 'Mercado Central SAJ',
phone: '(75) 99999-0001',
city: 'Santo Antonio de Jesus',
state: 'BA',
segment: 'mercados_de_bairro',
website: 'https://mercadocentral.example'
},
{
name: 'Clinica Vida Plena',
phone: '(75) 99999-0002',
city: 'Santo Antonio de Jesus',
state: 'BA',
segment: 'clinicas_medicas',
website: ''
},
{
name: 'Restaurante Sabor da Terra',
phone: '(75) 99999-0003',
city: 'Santo Antonio de Jesus',
state: 'BA',
segment: 'restaurantes',
website: ''
}
];
export async function* collect(params, ctx) {
const segments = params.segments || [];
const leads = MOCK_PLACES.map((place) => {
const segment = segments.find((item) => item.name === place.segment)
|| segments[0]
|| { name: place.segment, interest: 'ERP / sistema de gestao', pains: [] };
return buildLeadFromPlace(
{ ...place, query: `mock ${place.segment}` },
segment,
place.city,
place.state,
'mock'
);
});
ctx.emit({ phase: 'mock', message: `${leads.length} leads de teste gerados` });
yield { origin: 'mock', label: 'mock', leads };
}
+103
View File
@@ -0,0 +1,103 @@
import { cleanText } from '../lead-engine.mjs';
import { sleep } from '../util/collections.mjs';
import { UA_HEADERS, UF_NAMES, buildLeadFromPlace } from './common.mjs';
import { fetchTextWithTimeout } from '../util/fetch.mjs';
async function nominatimSearch(baseUrl, query, limit) {
const url = new URL(baseUrl);
url.searchParams.set('format', 'jsonv2');
url.searchParams.set('q', query);
url.searchParams.set('countrycodes', 'br');
url.searchParams.set('addressdetails', '1');
url.searchParams.set('limit', String(Math.max(1, limit)));
const { response, text } = await fetchTextWithTimeout(url, { headers: UA_HEADERS });
if (!response.ok) {
throw new Error(`Nominatim HTTP ${response.status}: ${text.slice(0, 300)}`);
}
return JSON.parse(text);
}
function isResultInTarget(result, city, uf) {
const address = result.address || {};
const resultCity = address.city || address.town || address.village || address.municipality || '';
const resultState = cleanText(address.state || '');
const expectedState = cleanText(UF_NAMES[uf] || uf);
const currentCity = cleanText(resultCity);
const expectedCity = cleanText(city);
return resultState === expectedState
&& (currentCity === expectedCity || currentCity.includes(expectedCity) || expectedCity.includes(currentCity));
}
function resultToPlace(result, segment, city, uf, query) {
const name = result.name || result.display_name?.split(',')[0];
if (!name) return null;
const address = result.address || {};
return {
name,
phone: '',
city: address.city || address.town || address.village || city,
state: uf,
segment: segment.name,
website: '',
address: result.display_name || '',
formatted_address: result.display_name || '',
query,
osm_id: String(result.osm_id || ''),
osm_type: result.osm_type || ''
};
}
// Fonte nominatim (OpenStreetMap): busca textual termo a termo por cidade,
// validando que o resultado pertence mesmo a cidade/UF pedida.
export async function* collect(params, ctx) {
const { uf, cities, segments, limits } = params;
if (!cities || !cities.length) {
ctx.log('nominatim: nenhuma cidade selecionada — fonte pulada');
return;
}
let executedQueries = 0;
for (const city of cities) {
const label = `${city} ${uf}`;
const cityLeads = [];
for (const segment of segments) {
for (const queryTerm of segment.queries || []) {
if (ctx.isCancelled()) return;
if (limits.queryLimit > 0 && executedQueries >= limits.queryLimit) {
if (cityLeads.length) yield { origin: 'nominatim', label, leads: cityLeads };
return;
}
const query = `${queryTerm} ${city} ${uf}`;
executedQueries += 1;
ctx.emit({ phase: 'nominatim', message: query });
let results = [];
try {
results = await nominatimSearch(ctx.endpoints.nominatimUrl, query, limits.maxPerQuery || 10);
} catch (error) {
ctx.log(`nominatim falhou em ${query}: ${error.message}`);
await sleep(Math.max(ctx.delays.overpassDelayMs, 5000));
continue;
}
for (const result of results) {
if (!isResultInTarget(result, city, uf)) continue;
const place = resultToPlace(result, segment, city, uf, query);
if (!place) continue;
cityLeads.push(buildLeadFromPlace(place, segment, city, uf, 'nominatim'));
}
await sleep(Math.max(ctx.delays.overpassDelayMs, 1200));
}
}
if (cityLeads.length) yield { origin: 'nominatim', label, leads: cityLeads };
}
}
+225
View File
@@ -0,0 +1,225 @@
import { cleanText } from '../lead-engine.mjs';
import { sleep } from '../util/collections.mjs';
import { UA_HEADERS, buildLeadFromPlace } from './common.mjs';
import { fetchTextWithTimeout } from '../util/fetch.mjs';
const OVERPASS_COOLDOWN_MS = 60000;
function overpassSelector(tag, bbox) {
const [south, west, north, east] = bbox;
const key = String(tag.key).replace(/"/g, '\\"');
const value = String(tag.value).replace(/"/g, '\\"');
const selector = `["${key}"="${value}"](${south},${west},${north},${east});`;
return [`node${selector}`, `way${selector}`, `relation${selector}`].join('\n');
}
export function escapeOverpassRegex(value) {
return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&').replace(/"/g, '\\"');
}
function overpassTextSelector(key, regex, bbox) {
const [south, west, north, east] = bbox;
const selector = `["${key}"~"${regex}",i](${south},${west},${north},${east});`;
return [`node${selector}`, `way${selector}`].join('\n');
}
function segmentTextRegex(segment) {
return (segment.queries || [])
.filter((term) => String(term).trim().length >= 6)
.slice(0, 8)
.map(escapeOverpassRegex)
.join('|');
}
function buildCombinedQuery(segments, bbox, includeText) {
const seen = new Set();
const selectors = [];
for (const segment of segments) {
for (const tag of segment.overpassTags || []) {
const key = `tag:${tag.key}=${tag.value}`;
if (seen.has(key)) continue;
seen.add(key);
selectors.push(overpassSelector(tag, bbox));
}
}
if (includeText) {
for (const segment of segments) {
const textRegex = segmentTextRegex(segment);
const key = `text:${segment.name}`;
if (!textRegex || seen.has(key)) continue;
seen.add(key);
selectors.push(overpassTextSelector('name', textRegex, bbox));
selectors.push(overpassTextSelector('brand', textRegex, bbox));
selectors.push(overpassTextSelector('operator', textRegex, bbox));
}
}
return ['[out:json][timeout:40];', '(', selectors.join('\n'), ');', 'out body center;'].join('\n');
}
function matchSegmentForElement(tags, segments, includeText) {
for (const segment of segments) {
for (const tag of segment.overpassTags || []) {
if (tags[tag.key] === tag.value) return segment;
}
}
if (includeText) {
const haystack = cleanText([tags.name, tags.brand, tags.operator].filter(Boolean).join(' '));
if (!haystack) return null;
for (const segment of segments) {
const terms = (segment.queries || []).filter((term) => String(term).trim().length >= 6);
if (terms.some((term) => haystack.includes(cleanText(term)))) return segment;
}
}
return null;
}
function valueFromTags(tags = {}, keys = []) {
for (const key of keys) {
if (tags[key]) return tags[key];
}
return '';
}
function elementToPlace(element, segment, city, uf, query) {
const tags = element.tags || {};
const name = tags.name || tags.brand || tags.operator;
if (!name) return null;
const street = valueFromTags(tags, ['addr:street']);
const housenumber = valueFromTags(tags, ['addr:housenumber']);
const address = [street, housenumber].filter(Boolean).join(', ');
return {
name,
phone: valueFromTags(tags, ['contact:phone', 'phone', 'mobile', 'contact:mobile']),
city: valueFromTags(tags, ['addr:city']) || city,
state: uf,
segment: segment.name,
website: valueFromTags(tags, ['contact:website', 'website', 'url']),
address,
formatted_address: address,
query,
osm_id: String(element.id || ''),
osm_type: element.type || ''
};
}
export function createOverpassClient({ urls, timeoutMs }) {
const failures = new Map();
return async function request(query) {
let lastError;
for (const apiUrl of urls) {
const state = failures.get(apiUrl);
if (state?.until && Date.now() < state.until) continue;
const recordFailure = () => {
const current = failures.get(apiUrl) || { count: 0, until: 0 };
const count = current.count + 1;
failures.set(apiUrl, { count, until: count >= 3 ? Date.now() + OVERPASS_COOLDOWN_MS : 0 });
};
const result = await fetchTextWithTimeout(apiUrl, {
method: 'POST',
headers: {
...UA_HEADERS,
'content-type': 'application/x-www-form-urlencoded;charset=UTF-8'
},
body: new URLSearchParams({ data: query })
}, timeoutMs, { maxBytes: 50 * 1024 * 1024 }).catch((error) => {
lastError = /timeout/i.test(error.message)
? new Error(`Overpass timeout apos ${timeoutMs}ms em ${apiUrl}`)
: error;
recordFailure();
return null;
});
if (!result) continue;
const { response, text } = result;
if (!response.ok) {
lastError = new Error(`Overpass HTTP ${response.status} em ${apiUrl}: ${text.slice(0, 300)}`);
recordFailure();
if (response.status === 429) await sleep(5000);
continue;
}
const data = JSON.parse(text);
failures.set(apiUrl, { count: 0, until: 0 });
return data.elements || [];
}
throw lastError || new Error('Overpass indisponivel: todos os endpoints em cooldown');
};
}
// Fonte overpass (OpenStreetMap): uma consulta combinada por cidade cobrindo
// todos os nichos selecionados, com failover entre endpoints e cooldown.
export async function* collect(params, ctx) {
const { uf, cities, segments, limits } = params;
if (!cities || !cities.length) {
ctx.log('overpass: nenhuma cidade selecionada — fonte pulada');
return;
}
const request = createOverpassClient({
urls: ctx.endpoints.overpassUrls,
timeoutMs: ctx.delays.overpassTimeoutMs
});
for (const city of cities) {
if (ctx.isCancelled()) return;
const label = `${city} ${uf}`;
let bbox = null;
try {
bbox = await ctx.geocode(uf, city);
} catch (error) {
ctx.log(`overpass: geocode falhou em ${label}: ${error.message}`);
}
if (!bbox) {
ctx.log(`overpass: sem bounding box para ${label} — cidade pulada`);
continue;
}
ctx.emit({ phase: 'overpass', message: `consultando ${label}` });
let elements = [];
try {
elements = await request(buildCombinedQuery(segments, bbox, ctx.overpassTextSearch));
} catch (error) {
ctx.log(`overpass falhou em ${label}: ${error.message}`);
await sleep(Math.max(ctx.delays.overpassDelayMs, 15000));
continue;
}
const perSegmentCount = new Map();
const cityLeads = [];
for (const element of elements) {
const segment = matchSegmentForElement(element.tags || {}, segments, ctx.overpassTextSearch);
if (!segment) continue;
if (limits.maxPerQuery > 0) {
const count = perSegmentCount.get(segment.name) || 0;
if (count >= limits.maxPerQuery) continue;
perSegmentCount.set(segment.name, count + 1);
}
const place = elementToPlace(element, segment, city, uf, `${segment.name} em ${label}`);
if (!place) continue;
cityLeads.push(buildLeadFromPlace(place, segment, city, uf, 'overpass'));
}
ctx.log(`overpass: ${elements.length} elementos brutos, ${cityLeads.length} leads em ${label}`);
if (cityLeads.length) yield { origin: 'overpass', label, leads: cityLeads };
await sleep(ctx.delays.overpassDelayMs);
}
}
+103
View File
@@ -0,0 +1,103 @@
import { cleanText } from '../lead-engine.mjs';
import { UA_HEADERS, buildLeadFromPlace } from './common.mjs';
import { escapeOverpassRegex } from './overpass.mjs';
import { fetchJsonWithTimeout } from '../util/fetch.mjs';
async function findWikidataCityId(city) {
// Busca por rotulo exato em SPARQL falha para nomes sem acento (base usa
// "Santo Antonio", Wikidata registra "Santo Antônio"). wbsearchentities
// e tolerante a acentos.
try {
const url = new URL('https://www.wikidata.org/w/api.php');
url.searchParams.set('action', 'wbsearchentities');
url.searchParams.set('search', city);
url.searchParams.set('language', 'pt');
url.searchParams.set('type', 'item');
url.searchParams.set('format', 'json');
const { response, data } = await fetchJsonWithTimeout(url, { headers: UA_HEADERS });
if (!response.ok) return '';
const match = (data.search || []).find((item) => {
const description = cleanText(item.description || '');
return description.includes('municipality') || description.includes('municipio') || description.includes('munic');
});
return match?.id || '';
} catch {
return '';
}
}
// Fonte wikidata: empresas registradas no Wikidata por municipio (SPARQL).
// Volume baixo, mas gratuito e util para achar empresas com website.
export async function* collect(params, ctx) {
const { uf, cities, segments, limits } = params;
let executedQueries = 0;
for (const city of cities || []) {
if (ctx.isCancelled()) return;
const cityId = await findWikidataCityId(city);
if (!cityId) {
ctx.log(`wikidata: municipio nao encontrado para ${city} — pulando`);
continue;
}
const label = `${city} ${uf}`;
const cityLeads = [];
for (const segment of segments) {
if (ctx.isCancelled()) return;
if (limits.queryLimit > 0 && executedQueries >= limits.queryLimit) break;
const regex = (segment.queries || [])
.filter((term) => term.length >= 4)
.slice(0, 10)
.map(escapeOverpassRegex)
.join('|');
if (!regex) continue;
const sparql = `
SELECT ?item ?itemLabel ?website WHERE {
?item wdt:P31/wdt:P279* wd:Q4830453.
{ ?item wdt:P131 wd:${cityId}. } UNION { ?item wdt:P159/wdt:P131* wd:${cityId}. }
OPTIONAL { ?item wdt:P856 ?website. }
SERVICE wikibase:label { bd:serviceParam wikibase:language "pt,en". }
FILTER(REGEX(?itemLabel, "${regex}", "i"))
}
LIMIT ${limits.maxPerQuery || 10}
`;
ctx.emit({ phase: 'wikidata', message: `${segment.name} em ${label}` });
executedQueries += 1;
try {
const url = new URL(ctx.endpoints.wikidataUrl);
url.searchParams.set('query', sparql);
url.searchParams.set('format', 'json');
const { response, data } = await fetchJsonWithTimeout(url, {
headers: { ...UA_HEADERS, accept: 'application/sparql-results+json' }
});
if (!response.ok) throw new Error(`Wikidata HTTP ${response.status}`);
for (const result of data.results?.bindings || []) {
const place = {
name: result.itemLabel?.value || '',
city,
state: uf,
segment: segment.name,
website: result.website?.value || '',
query: `wikidata ${segment.name} em ${label}`,
place_id: result.item?.value || ''
};
if (place.name) cityLeads.push(buildLeadFromPlace(place, segment, city, uf, 'wikidata'));
}
} catch (error) {
ctx.log(`wikidata falhou em ${segment.name} ${label}: ${error.message}`);
}
}
if (cityLeads.length) yield { origin: 'wikidata', label, leads: cityLeads };
}
}
+111
View File
@@ -0,0 +1,111 @@
import { buildLeadFilter } from './queries.mjs';
import { getSetting } from '../db.mjs';
function pct(parte, total) {
return total > 0 ? Math.round((parte / total) * 100) : 0;
}
export function dashboardSummary(db, filtro = {}) {
const { whereSql, params } = buildLeadFilter(filtro);
const row = db.prepare(
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN prioridade = 1 THEN 1 ELSE 0 END) AS p1,
SUM(CASE WHEN prioridade = 2 THEN 1 ELSE 0 END) AS p2,
SUM(CASE WHEN prioridade = 3 THEN 1 ELSE 0 END) AS p3,
SUM(CASE WHEN prioridade = 4 THEN 1 ELSE 0 END) AS p4,
SUM(telefone_valido) AS com_whatsapp,
SUM(CASE WHEN exported_at IS NOT NULL THEN 1 ELSE 0 END) AS exportados,
SUM(CASE WHEN exported_at IS NOT NULL AND date(exported_at) = date('now') THEN 1 ELSE 0 END) AS hoje_exportados
FROM leads ${whereSql}`
).get(...params);
const total = row.total || 0;
const exportados = row.exportados || 0;
const hojeExportados = row.hoje_exportados || 0;
const metaDiaria = Number(getSetting(db, 'meta_diaria', '100')) || 100;
const disponiveis = total - exportados;
return {
total,
p1: row.p1 || 0,
p2: row.p2 || 0,
p3: row.p3 || 0,
p4: row.p4 || 0,
p1_pct: pct(row.p1 || 0, total),
p2_pct: pct(row.p2 || 0, total),
p3_pct: pct(row.p3 || 0, total),
p4_pct: pct(row.p4 || 0, total),
potencial_pct: pct(row.p1 || 0, total),
com_whatsapp: row.com_whatsapp || 0,
sem_whatsapp: total - (row.com_whatsapp || 0),
exportados,
disponiveis,
cobertura_pct: pct(exportados, total),
hoje_exportados: hojeExportados,
meta_diaria: metaDiaria,
meta_pct: pct(hojeExportados, metaDiaria),
dias_estimados: metaDiaria > 0 ? Math.ceil(disponiveis / metaDiaria) : 0
};
}
const RANKING_COLUMNS = {
cidade: 'cidade',
segmento: 'segmento_label',
uf: 'uf',
interesse: 'interesse',
origem: 'origem',
produto: 'produto_principal'
};
export function dashboardRanking(db, by, filtro = {}, limit = 15) {
const column = RANKING_COLUMNS[by] || 'cidade';
const { whereSql, params } = buildLeadFilter(filtro);
const capped = Math.min(50, Math.max(1, Number(limit) || 15));
return db.prepare(
`SELECT ${column} AS label,
COUNT(*) AS total,
SUM(CASE WHEN prioridade = 1 THEN 1 ELSE 0 END) AS p1
FROM leads ${whereSql}
${whereSql ? 'AND' : 'WHERE'} ${column} != ''
GROUP BY ${column}
ORDER BY total DESC
LIMIT ?`
).all(...params, capped);
}
// Historico de exportacoes por dia — unico evento comercial com timestamp
// que o app registra (nao ha rastreio de contato/interesse/proposta).
export function dashboardDailyEvolution(db, filtro = {}, dias = 14) {
const { whereSql, params } = buildLeadFilter(filtro);
const capped = Math.min(90, Math.max(1, Number(dias) || 14));
const rows = db.prepare(
`SELECT date(exported_at) AS dia, COUNT(*) AS total
FROM leads ${whereSql}
${whereSql ? 'AND' : 'WHERE'} exported_at IS NOT NULL
GROUP BY dia
ORDER BY dia DESC
LIMIT ?`
).all(...params, capped);
return rows.reverse();
}
// Combinacao cidade+produto com mais empresas dentro do filtro atual.
export function dashboardMaiorOportunidade(db, filtro = {}) {
const { whereSql, params } = buildLeadFilter(filtro);
const row = db.prepare(
`SELECT cidade, produto_principal AS produto, COUNT(*) AS total
FROM leads ${whereSql}
${whereSql ? 'AND' : 'WHERE'} cidade != '' AND produto_principal != ''
GROUP BY cidade, produto_principal
ORDER BY total DESC
LIMIT 1`
).get(...params);
return row || null;
}
+188
View File
@@ -0,0 +1,188 @@
// Filtros de leads compartilhados entre a listagem, o dashboard e as
// exportacoes: o MESMO objeto de filtro que a tela usa para navegar e o que
// define quais leads entram num relatorio/exportacao.
import { getWhatsAppVerification } from '../db.mjs';
const SORTS = {
'prioridade:asc': 'prioridade ASC, score DESC, cidade ASC, empresa ASC',
'score:desc': 'score DESC, empresa ASC',
'score:asc': 'score ASC, empresa ASC',
'empresa:asc': 'empresa ASC',
'empresa:desc': 'empresa DESC',
'cidade:asc': 'cidade ASC, empresa ASC',
'captured_at:desc': "COALESCE(captured_at, created_at) DESC",
'captured_at:asc': "COALESCE(captured_at, created_at) ASC"
};
export function buildLeadFilter(filtro = {}) {
const where = [];
const params = [];
const eq = (column, value) => {
if (value === undefined || value === null || value === '') return;
where.push(`${column} = ?`);
params.push(value);
};
eq('uf', filtro.uf ? String(filtro.uf).toUpperCase() : '');
eq('cidade', filtro.cidade);
eq('segmento', filtro.segmento);
eq('classificacao', filtro.classificacao);
eq('interesse', filtro.interesse);
eq('origem', filtro.origem);
if (filtro.telefone_valido === 'sim') where.push('telefone_valido = 1');
if (filtro.telefone_valido === 'nao') where.push('telefone_valido = 0');
if (filtro.exportado === 'sim') where.push('exported_at IS NOT NULL');
if (filtro.exportado === 'nao') where.push('exported_at IS NULL');
if (filtro.website === 'sim') where.push("website != ''");
if (filtro.website === 'nao') where.push("website = ''");
if (filtro.email === 'sim') where.push("email != ''");
if (filtro.email === 'nao') where.push("email = ''");
eq('produto_principal', filtro.produto);
if (filtro.prioridade !== undefined && filtro.prioridade !== '') {
where.push('prioridade = ?');
params.push(Number(filtro.prioridade) || 4);
}
if (filtro.score_min !== undefined && filtro.score_min !== '') {
where.push('score >= ?');
params.push(Number(filtro.score_min) || 0);
}
if (filtro.q) {
const like = `%${String(filtro.q).trim()}%`;
where.push('(empresa LIKE ? OR legal_name LIKE ? OR whatsapp LIKE ? OR cnpj LIKE ?)');
params.push(like, like, like, like);
}
return {
whereSql: where.length ? `WHERE ${where.join(' AND ')}` : '',
params
};
}
export function listLeads(db, filtro = {}) {
const { whereSql, params } = buildLeadFilter(filtro);
const pageSize = Math.min(200, Math.max(1, Number(filtro.pageSize) || 50));
const page = Math.max(1, Number(filtro.page) || 1);
const orderBy = SORTS[filtro.sort] || SORTS['prioridade:asc'];
const total = db.prepare(`SELECT COUNT(*) AS t FROM leads ${whereSql}`).get(...params).t;
const rows = db.prepare(
`SELECT leads.id AS id, empresa, legal_name, whatsapp, ddd, cidade, uf, segmento, segmento_label,
interesse, cnpj, cnae, score, score_qualidade, score_comercial, classificacao,
prioridade, produto_principal, telefone_valido, origem,
exported_at, captured_at, segments.affinity AS afinidade
FROM leads LEFT JOIN segments ON segments.name = leads.segmento
${whereSql}
ORDER BY ${orderBy}
LIMIT ? OFFSET ?`
).all(...params, pageSize, (page - 1) * pageSize).map((lead) => withWhatsAppState(db, lead));
return { total, page, pageSize, rows };
}
export function getLead(db, id) {
const storedLead = db.prepare('SELECT * FROM leads WHERE id = ?').get(id);
const lead = storedLead ? withWhatsAppState(db, storedLead) : null;
if (!lead) return null;
const exportHistory = db.prepare(
`SELECT e.id, e.tipo, e.created_at, e.total_leads
FROM export_leads el JOIN exports e ON e.id = el.export_id
WHERE el.lead_id = ?
ORDER BY e.created_at DESC`
).all(id);
const segmentInfo = lead.segmento
? db.prepare('SELECT affinity FROM segments WHERE name = ?').get(lead.segmento)
: null;
const commercialState = db.prepare('SELECT * FROM outreach_lead_states WHERE lead_id = ?').get(id);
const stageHistory = db.prepare(
`SELECT id, campaign_id, stage, note, created_at
FROM outreach_stage_events
WHERE lead_id = ?
ORDER BY id DESC`
).all(id);
const appointments = db.prepare(
`SELECT * FROM outreach_appointments
WHERE lead_id = ?
ORDER BY id DESC`
).all(id);
return {
...lead,
pains: JSON.parse(lead.pains_json || '[]'),
produtos: JSON.parse(lead.produtos_json || '[]'),
afinidade: segmentInfo?.affinity || null,
exports: exportHistory,
commercial_state: commercialState || null,
stage_history: stageHistory,
appointments
};
}
function withWhatsAppState(db, lead) {
const verification = getWhatsAppVerification(db, lead.whatsapp);
return {
...lead,
whatsapp_status: verification.status,
whatsapp_numero_resolvido: verification.numero_resolvido,
whatsapp_checked_at: verification.checked_at
};
}
export function countLeads(db, filtro = {}) {
const { whereSql, params } = buildLeadFilter(filtro);
return db.prepare(`SELECT COUNT(*) AS t FROM leads ${whereSql}`).get(...params).t;
}
// Percorre todos os leads do filtro em blocos paginados por id — usado pelas
// exportacoes para nunca carregar dezenas de milhares de linhas de uma vez.
export function* iterateLeads(db, filtro = {}, chunkSize = 5000) {
const { whereSql, params } = buildLeadFilter(filtro);
const sql = `SELECT * FROM leads ${whereSql ? `${whereSql} AND` : 'WHERE'} id > ?
ORDER BY id LIMIT ?`;
const stmt = db.prepare(sql);
let lastId = 0;
while (true) {
const rows = stmt.all(...params, lastId, chunkSize);
if (!rows.length) return;
for (const row of rows) yield row;
lastId = rows[rows.length - 1].id;
}
}
// Valores distintos para os seletores de filtro da tela.
export function leadFacets(db, filtro = {}) {
const base = buildLeadFilter({ uf: filtro.uf });
const facet = (column) => db.prepare(
`SELECT ${column} AS value, COUNT(*) AS total FROM leads ${base.whereSql}
${base.whereSql ? 'AND' : 'WHERE'} ${column} != ''
GROUP BY ${column} ORDER BY total DESC LIMIT 300`
).all(...base.params);
return {
ufs: db.prepare("SELECT uf AS value, COUNT(*) AS total FROM leads WHERE uf != '' GROUP BY uf ORDER BY total DESC").all(),
cidades: facet('cidade'),
segmentos: facet('segmento'),
interesses: facet('interesse'),
origens: facet('origem'),
produtos: facet('produto_principal'),
classificacoes: db.prepare(
`SELECT classificacao AS value, COUNT(*) AS total FROM leads ${base.whereSql} GROUP BY classificacao`
).all(...base.params)
};
}
+27
View File
@@ -0,0 +1,27 @@
// Evita `target.push(...source)`: com arrays de dezenas/centenas de milhares de itens
// (bases CNPJ completas, varias cidades) o spread operator em argumentos de funcao
// estoura a pilha de chamadas do V8 (RangeError: Maximum call stack size exceeded).
export function pushAll(target, source) {
for (const item of source) target.push(item);
return target;
}
export function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Quebra o vinculo do V8 entre uma substring (fatia) e o buffer da string original.
// Sem isso, qualquer campo guardado a longo prazo mantem a linha CSV/JSON inteira
// viva na memoria (SlicedString), o que com centenas de milhares de linhas retidas
// simultaneamente estoura o heap.
export function copyStr(value) {
return Buffer.from(String(value ?? ''), 'utf8').toString('utf8');
}
export function chunk(array, size) {
const chunks = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
+122
View File
@@ -0,0 +1,122 @@
import fs from 'node:fs';
import readline from 'node:readline';
export const BOM = '';
export function splitCsvLine(line, separator = ';') {
const values = [];
let current = '';
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
const next = line[index + 1];
if (char === '"' && quoted && next === '"') {
current += '"';
index += 1;
} else if (char === '"') {
quoted = !quoted;
} else if (char === separator && !quoted) {
values.push(current.trim());
current = '';
} else {
current += char;
}
}
values.push(current.trim());
return values;
}
export function csvEscape(value) {
const text = String(value ?? '');
if (/[",\n;]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
return text;
}
export function neutralizeSpreadsheetFormula(value) {
const text = String(value ?? '');
return /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
}
// Formula de texto do Excel: impede que numeros longos (whatsapp, cnpj) virem
// notacao cientifica ao abrir o CSV. Mesmo truque usado nos arquivos TRATADOS.
export function excelTextCell(value) {
const text = String(value ?? '').replace(/"/g, '""');
return `"=""${text}"""`;
}
export function stripBom(text) {
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}
function detectSeparator(headerLine) {
return headerLine.includes(';') ? ';' : ',';
}
// Le um CSV linha a linha sem carregar o arquivo inteiro em memoria
// (a base CNPJ de uma UF inteira passa de 100MB). Emite um objeto por linha,
// chaveado pelos headers (opcionalmente normalizados). Campos entre aspas
// podem conter quebras de linha (ex.: descricoes de exportacao) — a paridade
// de aspas detecta o registro incompleto e acumula ate fechar.
export async function* streamCsvRows(filePath, { separator, normalizeHeader } = {}) {
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let headers = null;
let sep = separator;
let pending = '';
let pendingQuotes = 0;
try {
for await (const rawLine of rl) {
const piece = headers === null && !pending ? stripBom(rawLine) : rawLine;
pending = pending ? `${pending}\n${piece}` : piece;
pendingQuotes += (piece.match(/"/g) || []).length;
if (pendingQuotes % 2 === 1) continue;
const line = pending;
pending = '';
pendingQuotes = 0;
if (!line.trim()) continue;
if (headers === null) {
sep = sep || detectSeparator(line);
headers = splitCsvLine(line, sep).map((header) =>
normalizeHeader ? normalizeHeader(header) : header
);
continue;
}
const values = splitCsvLine(line, sep);
const row = {};
for (let i = 0; i < headers.length; i += 1) {
row[headers[i]] = values[i] || '';
}
yield row;
}
} finally {
rl.close();
stream.destroy();
}
}
// Writer de CSV em stream: grava BOM + header e depois linha a linha,
// sem montar o arquivo inteiro em memoria.
export function createCsvWriter(filePath, headers, { separator = ';' } = {}) {
const stream = fs.createWriteStream(filePath, { encoding: 'utf8' });
stream.write(BOM + headers.join(separator) + '\n');
return {
writeRow(values) {
return stream.write(values.join(separator) + '\n');
},
async end() {
await new Promise((resolve, reject) => {
stream.end((error) => (error ? reject(error) : resolve()));
});
}
};
}
+82
View File
@@ -0,0 +1,82 @@
export async function fetchWithTimeout(url, options = {}, timeoutMs = 25_000) {
const { fetch: fetchImpl = globalThis.fetch, ...fetchOptions } = options;
const timeout = new AbortController();
const signal = fetchOptions.signal
? AbortSignal.any([fetchOptions.signal, timeout.signal])
: timeout.signal;
const timer = setTimeout(
() => timeout.abort(new Error(`timeout de rede apos ${timeoutMs}ms`)),
timeoutMs
);
timer.unref?.();
try {
return await fetchImpl(url, { ...fetchOptions, signal });
} finally {
clearTimeout(timer);
}
}
async function consumeText(response, maxBytes) {
if (!response.body) return '';
const reader = response.body.getReader();
const chunks = [];
let total = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel();
throw new Error(`corpo excede limite de ${maxBytes} bytes`);
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(bytes);
}
async function fetchBodyWithTimeout(url, options, timeoutMs, consume) {
const { fetch: fetchImpl = globalThis.fetch, ...fetchOptions } = options;
const timeout = new AbortController();
const signal = fetchOptions.signal
? AbortSignal.any([fetchOptions.signal, timeout.signal])
: timeout.signal;
const timer = setTimeout(() => timeout.abort(new Error(`timeout de rede apos ${timeoutMs}ms`)), timeoutMs);
timer.unref?.();
let rejectAbort;
const aborted = new Promise((resolve, reject) => { rejectAbort = reject; });
const onAbort = () => rejectAbort(signal.reason || new Error('requisicao abortada'));
signal.addEventListener('abort', onAbort, { once: true });
try {
return await Promise.race([(async () => {
const response = await fetchImpl(url, { ...fetchOptions, signal });
return consume(response);
})(), aborted]);
} finally {
clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
}
}
export function fetchTextWithTimeout(url, options = {}, timeoutMs = 25_000, { maxBytes = 1_048_576 } = {}) {
return fetchBodyWithTimeout(url, options, timeoutMs, async (response) => ({
response,
text: await consumeText(response, maxBytes)
}));
}
export function fetchJsonWithTimeout(url, options = {}, timeoutMs = 25_000, { maxBytes = 10_485_760 } = {}) {
return fetchBodyWithTimeout(url, options, timeoutMs, async (response) => {
const text = await consumeText(response, maxBytes);
return { response, data: JSON.parse(text) };
});
}
+78
View File
@@ -0,0 +1,78 @@
import fs from 'node:fs';
// Le um arquivo JSON que contem um array de objetos SEM carregar o arquivo todo
// em memoria (o export legado de leads tem 472MB — JSON.parse direto estoura o heap).
// Scanner de profundidade: acha cada objeto de nivel superior do array e faz
// JSON.parse elemento a elemento (~3KB cada), mantendo a memoria constante.
// Suporta apenas arrays cujos elementos sao objetos (caso deste projeto).
export async function* streamJsonArray(filePath, { highWaterMark = 64 * 1024 } = {}) {
const stream = fs.createReadStream(filePath, { encoding: 'utf8', highWaterMark });
let arrayStarted = false;
let depth = 0;
let inString = false;
let escapeNext = false;
let parts = [];
let first = true;
try {
for await (let chunkText of stream) {
if (first) {
first = false;
if (chunkText.charCodeAt(0) === 0xfeff) chunkText = chunkText.slice(1);
}
let elementStart = -1;
for (let i = 0; i < chunkText.length; i += 1) {
const char = chunkText[i];
if (inString) {
if (escapeNext) {
escapeNext = false;
} else if (char === '\\') {
escapeNext = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
if (depth > 0) inString = true;
continue;
}
if (!arrayStarted) {
if (char === '[') arrayStarted = true;
continue;
}
if (char === '{') {
if (depth === 0) elementStart = i;
depth += 1;
} else if (char === '}') {
depth -= 1;
if (depth === 0) {
const startIndex = elementStart >= 0 ? elementStart : 0;
parts.push(chunkText.slice(startIndex, i + 1));
const text = parts.length === 1 ? parts[0] : parts.join('');
parts = [];
elementStart = -1;
yield JSON.parse(text);
}
}
}
if (depth > 0) {
parts.push(elementStart >= 0 ? chunkText.slice(elementStart) : chunkText);
}
}
} finally {
stream.destroy();
}
if (depth !== 0) {
throw new Error(`JSON truncado em ${filePath}: elemento incompleto no fim do arquivo`);
}
}
+648
View File
@@ -0,0 +1,648 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import { equivalentPhoneNumbers, normalizePhone } from '../phone.mjs';
let signalLogFilterUsers = 0;
let originalConsoleInfo;
let filteredConsoleInfo;
function acquireSignalLogFilter() {
if (signalLogFilterUsers === 0) {
originalConsoleInfo = console.info;
filteredConsoleInfo = (...args) => {
if (args[0] === 'Closing session:') return;
originalConsoleInfo(...args);
};
console.info = filteredConsoleInfo;
}
signalLogFilterUsers += 1;
let released = false;
return () => {
if (released) return;
released = true;
signalLogFilterUsers -= 1;
if (signalLogFilterUsers === 0 && console.info === filteredConsoleInfo) console.info = originalConsoleInfo;
};
}
export const WHATSAPP_STATES = Object.freeze({
STOPPED: 'STOPPED',
STARTING: 'STARTING',
QR_REQUIRED: 'QR_REQUIRED',
AUTHENTICATING: 'AUTHENTICATING',
READY: 'READY',
RECONNECTING: 'RECONNECTING',
DISCONNECTED: 'DISCONNECTED',
ERROR: 'ERROR'
});
function disconnectCode(error) {
return error?.output?.statusCode ?? error?.statusCode ?? error?.data?.statusCode;
}
function withTimeout(promise, timeoutMs, createError) {
let timer;
const provider = Promise.resolve(promise);
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(createError()), timeoutMs);
});
provider.catch(() => {});
return Promise.race([provider, timeout]).finally(() => clearTimeout(timer));
}
async function removeAuthDir(authDir) {
const resolved = path.resolve(authDir);
if (resolved === path.parse(resolved).root) throw new Error('authDir inseguro');
await fs.rm(resolved, { recursive: true, force: true });
}
export async function hasWhatsAppSession(authDir) {
try {
await fs.access(path.join(authDir, 'creds.json'));
return true;
} catch {
return false;
}
}
export function createWhatsAppManager({
authDir,
loadBaileys = () => import('baileys'),
qrTtlMs = 60_000,
reconnectDelayMs = 2_000,
maxReconnectAttempts = 5,
sendDrainTimeoutMs = 15_000,
lookupTimeoutMs = 10_000,
messageTimeoutMs = 30_000,
onMessageStatus = () => {},
onInboundMessage = () => {},
log = () => {}
}) {
if (!authDir) throw new Error('authDir obrigatorio');
const releaseSignalLogFilter = acquireSignalLogFilter();
let state = WHATSAPP_STATES.STOPPED;
let socket = null;
let connectPromise = null;
let sendQueue = Promise.resolve();
let qr = null;
let qrExpiresAt = 0;
let qrTimer = null;
let reconnectTimer = null;
let intentionalDisconnect = false;
let closed = false;
let lastError = null;
let generation = 0;
let reconnectAttempts = 0;
let lockToken = null;
let pendingSends = 0;
let cleanupPromise = null;
const pendingMessageStatuses = new Map();
const messageStatusInFlight = new Map();
const inboundMessageIds = new Set();
const lockPath = path.join(path.dirname(authDir), `${path.basename(authDir)}.lock`);
const recoveryPath = `${lockPath}.recovery`;
const beginCleanup = () => {
let resolve;
const promise = new Promise((done) => { resolve = done; });
cleanupPromise = promise;
return () => {
if (cleanupPromise === promise) cleanupPromise = null;
resolve();
};
};
const processAlive = (pid) => {
try { process.kill(pid, 0); return true; } catch { return false; }
};
const acquireLock = async () => {
if (lockToken) return;
await fs.mkdir(path.dirname(lockPath), { recursive: true });
const token = crypto.randomUUID();
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const handle = await fs.open(lockPath, 'wx');
try {
await handle.writeFile(JSON.stringify({ pid: process.pid, token }));
} finally {
await handle.close();
}
lockToken = token;
return;
} catch (error) {
if (error.code !== 'EEXIST') throw error;
const recoveryToken = crypto.randomUUID();
let recoveryHandle;
try {
recoveryHandle = await fs.open(recoveryPath, 'wx');
} catch (recoveryError) {
if (recoveryError.code === 'EEXIST') {
let recoveryHolder;
try {
recoveryHolder = JSON.parse(await fs.readFile(recoveryPath, 'utf8'));
} catch (readError) {
if (readError.code === 'ENOENT') continue;
if (!(readError instanceof SyntaxError)) throw readError;
}
if (recoveryHolder?.pid && processAlive(recoveryHolder.pid)) {
throw new Error(`recuperacao do lock do authDir em andamento (PID ${recoveryHolder.pid})`);
}
const staleRecovery = `${recoveryPath}.stale-${process.pid}-${crypto.randomUUID()}`;
try {
await fs.rename(recoveryPath, staleRecovery);
} catch (renameError) {
if (renameError.code === 'ENOENT') continue;
throw renameError;
}
await fs.rm(staleRecovery, { force: true });
recoveryHandle = await fs.open(recoveryPath, 'wx');
} else {
throw recoveryError;
}
}
try {
try {
await recoveryHandle.writeFile(JSON.stringify({ pid: process.pid, token: recoveryToken }));
} finally {
await recoveryHandle.close();
}
let holder;
try {
holder = JSON.parse(await fs.readFile(lockPath, 'utf8'));
} catch (readError) {
if (readError.code === 'ENOENT') continue;
if (readError instanceof SyntaxError) holder = null;
else throw readError;
}
if (holder?.pid && processAlive(holder.pid)) throw new Error(`authDir em uso por outro processo (PID ${holder.pid})`);
const stalePath = `${lockPath}.stale-${process.pid}-${crypto.randomUUID()}`;
try {
await fs.rename(lockPath, stalePath);
} catch (renameError) {
if (renameError.code === 'ENOENT') continue;
throw renameError;
}
await fs.rm(stalePath, { force: true });
} finally {
try {
const recoveryHolder = JSON.parse(await fs.readFile(recoveryPath, 'utf8'));
if (recoveryHolder.token === recoveryToken) await fs.rm(recoveryPath, { force: true });
} catch (releaseError) {
if (releaseError.code !== 'ENOENT') throw releaseError;
}
}
}
}
throw new Error('nao foi possivel adquirir lock do authDir');
};
const releaseLock = async () => {
const token = lockToken;
lockToken = null;
if (!token) return;
try {
const holder = JSON.parse(await fs.readFile(lockPath, 'utf8'));
if (holder.token === token) await fs.rm(lockPath, { force: true });
} catch {
// lock ja removido
}
};
const clearQr = () => {
qr = null;
qrExpiresAt = 0;
if (qrTimer) clearTimeout(qrTimer);
qrTimer = null;
};
const setQr = (value) => {
clearQr();
qr = value;
qrExpiresAt = Date.now() + qrTtlMs;
state = WHATSAPP_STATES.QR_REQUIRED;
qrTimer = setTimeout(() => {
clearQr();
if (state === WHATSAPP_STATES.QR_REQUIRED) state = WHATSAPP_STATES.AUTHENTICATING;
}, qrTtlMs);
qrTimer.unref?.();
};
const scheduleReconnect = (delay = reconnectDelayMs) => {
if (closed || intentionalDisconnect || reconnectTimer) return;
if (reconnectAttempts >= maxReconnectAttempts) {
lastError = `limite de ${maxReconnectAttempts} reconexoes atingido`;
state = WHATSAPP_STATES.ERROR;
return;
}
reconnectAttempts += 1;
state = WHATSAPP_STATES.RECONNECTING;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect({ manual: false }).catch((error) => {
lastError = String(error.message || error);
if (!reconnectTimer) state = WHATSAPP_STATES.ERROR;
log(`whatsapp: reconexao falhou: ${lastError}`);
});
}, delay);
reconnectTimer.unref?.();
};
const handleUpdate = async (currentSocket, baileys, update) => {
if (currentSocket !== socket) return;
if (update.qr) setQr(update.qr);
if (update.connection === 'connecting' && !update.qr) {
state = qr ? WHATSAPP_STATES.AUTHENTICATING : WHATSAPP_STATES.STARTING;
}
if (update.connection === 'open') {
clearQr();
lastError = null;
state = WHATSAPP_STATES.READY;
reconnectAttempts = 0;
return;
}
if (update.connection !== 'close') return;
clearQr();
socket = null;
const code = disconnectCode(update.lastDisconnect?.error);
const fatalReasons = new Set([
baileys.DisconnectReason?.loggedOut,
baileys.DisconnectReason?.badSession,
baileys.DisconnectReason?.forbidden,
baileys.DisconnectReason?.multideviceMismatch,
baileys.DisconnectReason?.connectionReplaced
]);
if (fatalReasons.has(code)) {
const finishCleanup = beginCleanup();
intentionalDisconnect = true;
try {
if (code === baileys.DisconnectReason?.loggedOut) {
await removeAuthDir(authDir);
}
state = code === baileys.DisconnectReason?.loggedOut ? WHATSAPP_STATES.DISCONNECTED : WHATSAPP_STATES.ERROR;
lastError = code === baileys.DisconnectReason?.connectionReplaced
? 'conexao substituida por outra instancia'
: `falha fatal do WhatsApp (${code})`;
await releaseLock();
} finally {
finishCleanup();
}
return;
}
log(`whatsapp: conexao encerrada (${code ?? 'sem codigo'}); tentando reconectar`);
if (intentionalDisconnect || closed) return;
scheduleReconnect(code === baileys.DisconnectReason?.restartRequired ? 0 : reconnectDelayMs);
};
function connect({ manual = true } = {}) {
if (closed) return Promise.reject(new Error('manager encerrado'));
if (cleanupPromise) return cleanupPromise.then(() => connect({ manual }));
if (socket || connectPromise) return connectPromise || Promise.resolve(getStatus());
if (manual) reconnectAttempts = 0;
intentionalDisconnect = false;
const currentGeneration = ++generation;
state = WHATSAPP_STATES.STARTING;
lastError = null;
connectPromise = (async () => {
try {
await acquireLock();
const baileys = await loadBaileys();
if (currentGeneration !== generation || closed || intentionalDisconnect) {
await releaseLock();
return getStatus();
}
const { state: authState, saveCreds } = await baileys.useMultiFileAuthState(authDir);
if (currentGeneration !== generation || closed || intentionalDisconnect) {
await releaseLock();
return getStatus();
}
const currentSocket = baileys.default({ auth: authState, printQRInTerminal: false });
if (currentGeneration !== generation || closed || intentionalDisconnect) {
currentSocket.end?.(new Error('conexao cancelada'));
await releaseLock();
return getStatus();
}
socket = currentSocket;
currentSocket.ev.on('creds.update', (...args) => {
Promise.resolve(saveCreds(...args)).catch((error) => {
lastError = `falha ao salvar credenciais: ${error.message || error}`;
state = WHATSAPP_STATES.ERROR;
log(`whatsapp: ${lastError}`);
});
});
currentSocket.ev.on('connection.update', (update) => {
handleUpdate(currentSocket, baileys, update).catch((error) => {
lastError = String(error.message || error);
state = WHATSAPP_STATES.ERROR;
log(`whatsapp: falha ao processar conexao: ${lastError}`);
});
});
currentSocket.ev.on('messages.upsert', (upsert) => {
if (currentSocket !== socket || upsert?.type !== 'notify' || !Array.isArray(upsert.messages)) return;
for (const message of upsert.messages) {
const messageId = message?.key?.id;
const jid = message?.key?.remoteJid;
const match = typeof jid === 'string' ? /^(\d+)(?::\d+)?@s\.whatsapp\.net$/.exec(jid) : null;
if (!messageId || message.key.fromMe !== false || !match || inboundMessageIds.has(String(messageId))) continue;
const number = normalizePhone(match[1]);
if (!number) continue;
const rawTimestamp = typeof message.messageTimestamp?.toNumber === 'function'
? message.messageTimestamp.toNumber()
: Number(message.messageTimestamp);
const timestampMs = Number.isFinite(rawTimestamp)
? rawTimestamp * (rawTimestamp < 1e12 ? 1_000 : 1)
: Date.now();
inboundMessageIds.add(String(messageId));
Promise.resolve().then(() => onInboundMessage({
messageId: String(messageId),
jid,
number,
receivedAt: new Date(timestampMs).toISOString()
})).catch((error) => log(`whatsapp: falha ao persistir mensagem recebida: ${error.message || error}`));
}
});
currentSocket.ev.on('messages.update', (updates) => {
if (currentSocket !== socket || !Array.isArray(updates)) return;
const statusNames = { 0: 'erro', 2: 'enviado', 3: 'entregue', 4: 'lido', 5: 'lido' };
for (const item of updates) {
const status = statusNames[item?.update?.status];
const messageId = item?.key?.id;
if (!status || !messageId || item?.key?.fromMe === false) continue;
const event = {
messageId: String(messageId),
jid: item.key.remoteJid || null,
status
};
const applying = Promise.resolve().then(() => onMessageStatus(event)).then((applied) => {
if (applied) {
pendingMessageStatuses.delete(event.messageId);
} else if (pendingSends > 0) {
const ranks = { erro: 5, enviado: 2, entregue: 3, lido: 4 };
const previous = pendingMessageStatuses.get(event.messageId);
if (!previous || ranks[event.status] > ranks[previous.status]) pendingMessageStatuses.set(event.messageId, event);
}
return applied;
});
messageStatusInFlight.set(event.messageId, applying);
applying.catch((error) => log(`whatsapp: falha ao persistir status da mensagem: ${error.message || error}`))
.finally(() => {
if (messageStatusInFlight.get(event.messageId) === applying) messageStatusInFlight.delete(event.messageId);
});
}
});
return getStatus();
} catch (error) {
socket = null;
lastError = String(error.message || error);
state = WHATSAPP_STATES.ERROR;
await releaseLock();
if (!closed && !intentionalDisconnect) scheduleReconnect();
throw error;
} finally {
connectPromise = null;
}
})();
return connectPromise;
}
function getStatus() {
return {
state,
connected: state === WHATSAPP_STATES.READY,
qrExpiresAt: qrExpiresAt ? new Date(qrExpiresAt).toISOString() : null,
error: lastError,
pendingSends
};
}
function getQr() {
if (!qr || Date.now() >= qrExpiresAt) {
clearQr();
return null;
}
return qr;
}
const resolutionMetadata = (digits, candidates, registered) => {
const resolvedNumber = registered?.normalizedNumber || null;
return {
numero: digits,
status: registered ? 'confirmado' : 'nao_cadastrado',
registered: Boolean(registered),
resolvedNumber,
variant: !registered ? null : resolvedNumber === digits ? 'exata' : digits.length === 13 ? 'sem_nono_digito' : 'com_nono_digito',
candidatesChecked: candidates.length
};
};
const temporaryResolutionMetadata = (digits, candidates) => ({
numero: digits,
status: 'erro_temporario',
registered: false,
resolvedNumber: null,
variant: null,
candidatesChecked: candidates.length
});
async function resolveNumber(number) {
const digits = normalizePhone(number);
if (!digits) {
const error = new Error('numero obrigatorio');
error.statusCode = 400;
throw error;
}
const candidates = equivalentPhoneNumbers(digits);
if (state !== WHATSAPP_STATES.READY || !socket) {
throw Object.assign(new Error('WhatsApp nao esta conectado'), temporaryResolutionMetadata(digits, candidates));
}
const providerSocket = socket;
let matches;
try {
matches = await withTimeout(
Promise.resolve().then(() => providerSocket.onWhatsApp(...candidates)),
lookupTimeoutMs,
() => Object.assign(new Error(`consulta do numero no WhatsApp excedeu ${lookupTimeoutMs}ms`), {
statusCode: 504,
code: 'WHATSAPP_LOOKUP_TIMEOUT'
})
);
} catch (error) {
throw Object.assign(error, temporaryResolutionMetadata(digits, candidates));
}
const candidateSet = new Set(candidates.map((candidate) => normalizePhone(candidate)).filter(Boolean));
const available = (matches || [])
.filter((item) => item?.exists && item?.jid)
.map((item) => ({
...item,
normalizedNumber: normalizePhone(item.jid.split('@')[0].split(':')[0])
}))
.filter((item) => candidateSet.has(item.normalizedNumber));
const registered = available.find((item) => item.normalizedNumber === digits) || available[0] || null;
return { providerSocket, registered, metadata: resolutionMetadata(digits, candidates, registered) };
}
async function checkNumber(number) {
const { metadata } = await resolveNumber(number);
return metadata;
}
function sendText({ number, message, externalKey, beforeSend, afterSend }) {
pendingSends += 1;
const operation = sendQueue.then(async () => {
const text = String(message || '').trim();
if (!text) throw new Error('mensagem obrigatoria');
const { providerSocket, registered, metadata } = await resolveNumber(number);
if (!registered) {
const error = new Error('numero nao esta cadastrado no WhatsApp');
error.statusCode = 422;
throw Object.assign(error, metadata);
}
const context = await beforeSend?.();
let result;
try {
if (providerSocket !== socket || state !== WHATSAPP_STATES.READY) {
throw Object.assign(new Error('conexao do WhatsApp mudou antes do envio'), {
statusCode: 503,
code: 'WHATSAPP_SOCKET_STALE',
numero: metadata.numero,
resolvedNumber: metadata.resolvedNumber,
variant: metadata.variant
});
}
const response = await withTimeout(
Promise.resolve().then(() => providerSocket.sendMessage(registered.jid, { text })),
messageTimeoutMs,
() => Object.assign(new Error(`envio pelo WhatsApp excedeu ${messageTimeoutMs}ms`), {
statusCode: 504,
code: 'WHATSAPP_SEND_TIMEOUT',
deliveryUnknown: true,
numero: metadata.numero,
resolvedNumber: metadata.resolvedNumber,
variant: metadata.variant
})
);
const messageId = response?.key?.id;
if (!messageId) {
throw Object.assign(new Error('WhatsApp nao retornou o identificador da mensagem'), {
code: 'WHATSAPP_MESSAGE_ID_MISSING',
deliveryUnknown: true
});
}
result = { messageId: String(messageId), resolvedNumber: metadata.resolvedNumber, variant: metadata.variant };
} catch (error) {
Object.assign(error, {
numero: error.numero ?? metadata.numero,
resolvedNumber: error.resolvedNumber ?? metadata.resolvedNumber,
variant: error.variant ?? metadata.variant
});
if (error.code === 'WHATSAPP_SEND_TIMEOUT' && providerSocket === socket) {
generation += 1;
socket = null;
clearQr();
try {
providerSocket.end?.(error);
} catch (endError) {
log(`whatsapp: falha ao invalidar socket apos timeout de envio: ${endError.message || endError}`);
}
if (!closed && !intentionalDisconnect) scheduleReconnect(0);
}
const persisted = await afterSend?.(null, error, context);
if (afterSend && persisted === false) throw new Error('Falha ao persistir resultado do envio', { cause: error });
throw error;
}
try {
const persisted = await afterSend?.(result, null, context);
if (afterSend && persisted === false) throw new Error('registro de envio removido durante persistencia');
await messageStatusInFlight.get(result.messageId)?.catch(() => {});
const pendingStatus = pendingMessageStatuses.get(result.messageId);
if (pendingStatus) {
try {
if (await onMessageStatus(pendingStatus)) pendingMessageStatuses.delete(result.messageId);
} catch (error) {
log(`whatsapp: falha ao reaplicar status da mensagem: ${error.message || error}`);
}
}
} catch (cause) {
const error = new Error(`Falha ao persistir confirmacao da mensagem ${result.messageId}`, { cause });
error.messageId = result.messageId;
error.deliverySucceeded = true;
throw error;
}
return result;
}).finally(() => { pendingSends -= 1; });
sendQueue = operation.catch(() => {});
return operation;
}
async function disconnect({ logout = false } = {}) {
if (cleanupPromise) await cleanupPromise;
const finishCleanup = beginCleanup();
generation += 1;
intentionalDisconnect = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = null;
clearQr();
const currentSocket = socket;
socket = null;
state = logout ? WHATSAPP_STATES.DISCONNECTED : WHATSAPP_STATES.STOPPED;
let failure;
try {
let timer;
const drained = await Promise.race([
sendQueue.then(() => true),
new Promise((resolve) => { timer = setTimeout(() => resolve(false), sendDrainTimeoutMs); })
]);
clearTimeout(timer);
if (!drained) throw new Error(`fila de envios excedeu o prazo de ${sendDrainTimeoutMs}ms`);
} catch (error) {
failure = error;
}
try {
if (currentSocket) {
if (logout) await currentSocket.logout();
else currentSocket.end?.(new Error('desconectado pelo usuario'));
}
} catch (error) {
failure ||= error;
}
try {
if (logout) await removeAuthDir(authDir);
} catch (error) {
failure ||= error;
} finally {
state = logout ? WHATSAPP_STATES.DISCONNECTED : WHATSAPP_STATES.STOPPED;
pendingMessageStatuses.clear();
messageStatusInFlight.clear();
inboundMessageIds.clear();
await releaseLock();
finishCleanup();
}
if (failure) throw failure;
return getStatus();
}
async function refreshQr() {
if (state === WHATSAPP_STATES.READY) throw new Error('WhatsApp ja esta conectado; use sair e esquecer sessao para trocar a conta');
await disconnect({ logout: false });
reconnectAttempts = 0;
return connect();
}
async function close() {
closed = true;
try {
await disconnect();
} finally {
releaseSignalLogFilter();
}
}
return { getStatus, connect, refreshQr, getQr, checkNumber, disconnect, sendText, close };
}