chore: import maquina-comercial-pessoal history via subtree

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

git-subtree-dir: maquina-comercial-pessoal
git-subtree-mainline: f5af038e19
git-subtree-split: a9ef4d776d
This commit is contained in:
Gustavo
2026-09-28 15:50:17 -03:00
co-authored by Claude Sonnet 5
150 changed files with 33449 additions and 0 deletions
@@ -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 });
});
}