chore: import maquina-comercial-pessoal history via subtree
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> git-subtree-dir: maquina-comercial-pessoal git-subtree-mainline:f5af038e19git-subtree-split:a9ef4d776d
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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) });
|
||||
});
|
||||
}
|
||||
@@ -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})` });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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})` });
|
||||
});
|
||||
}
|
||||
@@ -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)) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { paths } from '../../env.mjs';
|
||||
import { UF_NAMES } from '../../sources/common.mjs';
|
||||
import { describeSources } from '../../sources/index.mjs';
|
||||
import { getSetting, setSetting, resetLeadsBase } from '../../db.mjs';
|
||||
import { cleanText } from '../../lead-engine.mjs';
|
||||
|
||||
// Senha de confirmacao para a limpeza total da base (acao destrutiva e
|
||||
// irreversivel) — pedida no modal de Configuracoes antes de zerar leads,
|
||||
// exportacoes, capturas e extracoes. Vem do .env (nunca versionado).
|
||||
// Funcao (nao const) de proposito: os imports deste arquivo rodam antes do
|
||||
// "await loadDotEnv()" em scripts/servidor.mjs (imports sao hoisted acima de
|
||||
// qualquer top-level await), entao ler process.env.APP_RESET_PASSWORD direto
|
||||
// num const no topo do modulo sempre capturava undefined — nenhuma senha
|
||||
// nunca batia, mesmo a correta. Lendo dentro da rota (call-time) o .env ja
|
||||
// foi carregado quando a chamada realmente acontece.
|
||||
function resetBasePassword() {
|
||||
return process.env.APP_RESET_PASSWORD;
|
||||
}
|
||||
|
||||
const EDITABLE_SETTINGS = new Set([
|
||||
'meta_diaria',
|
||||
'outreach.ativo',
|
||||
'outreach.mensagensPorDia'
|
||||
]);
|
||||
|
||||
function validateSetting(key, value) {
|
||||
if (key === 'outreach.ativo') return typeof value === 'string' && (value === 'true' || value === 'false');
|
||||
const number = Number(value);
|
||||
if (!Number.isInteger(number)) return false;
|
||||
if (key === 'meta_diaria') return number >= 1 && number <= 100000;
|
||||
if (key === 'outreach.mensagensPorDia') return number >= 1 && number <= 1000;
|
||||
return false;
|
||||
}
|
||||
|
||||
function requireAdmin(user, json) {
|
||||
if (user?.role === 'admin') return true;
|
||||
json(403, { erro: 'apenas administradores podem alterar configuracoes' });
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractedCsvForUf(uf) {
|
||||
const candidates = [
|
||||
path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`),
|
||||
// nome antigo da primeira extracao (Bahia)
|
||||
...(uf === 'BA' ? [path.join(paths.cnpjDir, 'estabelecimentos-bahia.csv')] : [])
|
||||
];
|
||||
return candidates.find((file) => fs.existsSync(file)) || null;
|
||||
}
|
||||
|
||||
export function registerMetaRoutes(router, { db, jobs, whatsapp = { getStatus: () => ({ pendingSends: 0 }) } }) {
|
||||
router.add('GET', '/api/health', async (req, res, { json }) => {
|
||||
json(200, {
|
||||
ok: true,
|
||||
app: 'maquina-comercial-pessoal',
|
||||
database: 'open',
|
||||
whatsapp: whatsapp.getStatus?.().state || 'UNKNOWN'
|
||||
});
|
||||
});
|
||||
|
||||
router.add('GET', '/api/meta/ufs', async (req, res, { json }) => {
|
||||
const doneByUf = new Map(
|
||||
db.prepare(
|
||||
`SELECT uf, MAX(finished_at) AS finished_at, total_rows, municipios_count
|
||||
FROM extractions WHERE status = 'done' GROUP BY uf`
|
||||
).all().map((row) => [row.uf, row])
|
||||
);
|
||||
const municipiosByUf = new Map(
|
||||
db.prepare('SELECT uf, COUNT(*) AS total FROM municipios GROUP BY uf').all()
|
||||
.map((row) => [row.uf, row.total])
|
||||
);
|
||||
const runningUfs = new Set(
|
||||
db.prepare("SELECT uf FROM extractions WHERE status IN ('pending','running')").all().map((r) => r.uf)
|
||||
);
|
||||
|
||||
const ufs = Object.entries(UF_NAMES).map(([uf, nome]) => {
|
||||
const csv = extractedCsvForUf(uf);
|
||||
const done = doneByUf.get(uf);
|
||||
return {
|
||||
uf,
|
||||
nome,
|
||||
municipios: municipiosByUf.get(uf) || 0,
|
||||
extraida: Boolean(csv),
|
||||
extraindo: runningUfs.has(uf),
|
||||
extracao: done ? { finished_at: done.finished_at, total_rows: done.total_rows } : null
|
||||
};
|
||||
});
|
||||
|
||||
json(200, { ufs });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/meta/municipios', async (req, res, { json, query }) => {
|
||||
const uf = String(query.get('uf') || '').toUpperCase();
|
||||
if (!UF_NAMES[uf]) {
|
||||
json(400, { erro: 'informe uf valida, ex.: ?uf=BA' });
|
||||
return;
|
||||
}
|
||||
|
||||
const q = String(query.get('q') || '').toLowerCase();
|
||||
let rows = db.prepare('SELECT codigo_ibge, nome, nome_clean, uf FROM municipios WHERE uf = ? ORDER BY nome').all(uf);
|
||||
if (q) rows = rows.filter((row) => row.nome_clean.includes(q));
|
||||
|
||||
json(200, { municipios: rows.map((r) => ({ codigo: r.codigo_ibge, nome: r.nome })) });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/meta/segments', async (req, res, { json }) => {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, name, label, interest, cnae_codes_json FROM segments WHERE active = 1 ORDER BY label'
|
||||
).all();
|
||||
json(200, {
|
||||
segments: rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
label: row.label,
|
||||
interesse: row.interest,
|
||||
cnaes: JSON.parse(row.cnae_codes_json).length
|
||||
}))
|
||||
});
|
||||
});
|
||||
|
||||
// Busca livre em todas as ~1359 atividades (CNAE) da Receita Federal — usada
|
||||
// para adicionar nichos avulsos alem dos 57 pre-configurados em segments.
|
||||
router.add('GET', '/api/meta/cnaes', async (req, res, { json, query }) => {
|
||||
const q = cleanText(String(query.get('q') || ''));
|
||||
if (q.length < 2) {
|
||||
json(200, { cnaes: [] });
|
||||
return;
|
||||
}
|
||||
const rows = db.prepare('SELECT codigo, descricao FROM cnaes').all();
|
||||
const achados = rows
|
||||
.filter((r) => cleanText(r.descricao).includes(q) || r.codigo.startsWith(q))
|
||||
.slice(0, 30);
|
||||
json(200, { cnaes: achados });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/meta/sources', async (req, res, { json }) => {
|
||||
json(200, { sources: describeSources() });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/settings', async (req, res, { json }) => {
|
||||
const rows = db.prepare('SELECT key, value FROM settings ORDER BY key').all();
|
||||
json(200, { settings: Object.fromEntries(rows.map((r) => [r.key, r.value])) });
|
||||
});
|
||||
|
||||
router.add('PUT', '/api/settings', async (req, res, { json, body, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
if (!body || typeof body !== 'object') {
|
||||
json(400, { erro: 'corpo JSON esperado: {"chave": "valor"}' });
|
||||
return;
|
||||
}
|
||||
const invalidKeys = Object.keys(body).filter((key) => !EDITABLE_SETTINGS.has(key));
|
||||
if (invalidKeys.length) {
|
||||
json(400, { erro: `configuracoes nao permitidas: ${invalidKeys.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
const invalidValues = Object.entries(body).filter(([key, value]) => !validateSetting(key, value));
|
||||
if (invalidValues.length) {
|
||||
json(400, { erro: `valores invalidos: ${invalidValues.map(([key]) => key).join(', ')}` });
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
setSetting(db, key, value);
|
||||
}
|
||||
json(200, { ok: true, atualizadas: Object.keys(body).length });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/jobs/active', async (req, res, { json }) => {
|
||||
json(200, { jobs: jobs.activeJobs() });
|
||||
});
|
||||
|
||||
// Zera toda a base de leads (irreversivel): exige a senha de confirmacao.
|
||||
// 403 (nao 401): o usuario ja esta autenticado, so a senha de confirmacao
|
||||
// da acao esta errada. 401 aqui disparava o redirect global de "sessao
|
||||
// expirada" do api.js antes do erro aparecer na tela — o usuario via a
|
||||
// tela de login e achava que so tinha caido a sessao, sem saber que a
|
||||
// limpeza nunca rodou.
|
||||
router.add('POST', '/api/admin/reset', async (req, res, { json, body, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
const configuredPassword = resetBasePassword();
|
||||
if (typeof configuredPassword !== 'string' || !configuredPassword.trim()) {
|
||||
json(503, { erro: 'configure APP_RESET_PASSWORD no arquivo .env antes de usar o reset' });
|
||||
return;
|
||||
}
|
||||
if (!body || typeof body.senha !== 'string' || !body.senha || body.senha !== configuredPassword) {
|
||||
json(403, { erro: 'senha incorreta' });
|
||||
return;
|
||||
}
|
||||
if (jobs.activeJobs().length) {
|
||||
json(409, { erro: 'nao e possivel limpar a base enquanto ha jobs ativos' });
|
||||
return;
|
||||
}
|
||||
if ((whatsapp.getStatus()?.pendingSends || 0) > 0) {
|
||||
json(409, { erro: 'nao e possivel limpar a base enquanto ha envios WhatsApp pendentes' });
|
||||
return;
|
||||
}
|
||||
const persistentJob = ['capture_runs', 'extractions', 'exports'].some((table) =>
|
||||
db.prepare(`SELECT 1 FROM ${table} WHERE status IN ('pending','running') LIMIT 1`).get()
|
||||
);
|
||||
if (persistentJob) {
|
||||
json(409, { erro: 'nao e possivel limpar a base enquanto ha jobs persistentes ativos' });
|
||||
return;
|
||||
}
|
||||
|
||||
resetLeadsBase(db);
|
||||
fs.rmSync(paths.exports, { recursive: true, force: true });
|
||||
fs.mkdirSync(paths.exports, { recursive: true });
|
||||
|
||||
json(200, { ok: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
addOptOut,
|
||||
addSharedNumberOverride,
|
||||
countDailyOutreachUsage,
|
||||
getSetting,
|
||||
isNumberSharedAcrossLeads,
|
||||
isOptedOut,
|
||||
isSharedNumberOverridden,
|
||||
listMessageTemplates,
|
||||
listOptOuts,
|
||||
listSharedNumberGroups,
|
||||
removeOptOut,
|
||||
removeSharedNumberOverride,
|
||||
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;
|
||||
}
|
||||
if (isNumberSharedAcrossLeads(db, number) && !isSharedNumberOverridden(db, number)) {
|
||||
json(409, { ok: false, erro: 'numero compartilhado por varios leads - revise em Configuracoes antes de enviar' });
|
||||
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 });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/outreach/shared-numbers', async (req, res, { json }) => {
|
||||
json(200, { groups: listSharedNumberGroups(db) });
|
||||
});
|
||||
|
||||
router.add('POST', '/api/outreach/shared-numbers/:numero/liberar', async (req, res, { json, params, body, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
const motivo = String(body?.motivo || '').trim();
|
||||
if (!motivo) {
|
||||
json(400, { erro: 'motivo obrigatorio para liberar numero compartilhado' });
|
||||
return;
|
||||
}
|
||||
json(201, { override: addSharedNumberOverride(db, params.numero, motivo) });
|
||||
});
|
||||
|
||||
router.add('DELETE', '/api/outreach/shared-numbers/:numero/liberar', async (req, res, { json, params, user }) => {
|
||||
if (!requireAdmin(user, json)) return;
|
||||
if (!removeSharedNumberOverride(db, params.numero)) {
|
||||
json(404, { erro: 'override nao encontrado' });
|
||||
return;
|
||||
}
|
||||
json(200, { ok: true });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user