This work already existed uncommitted in the main working tree (not authored by this branch) and is required as a runtime dependency for the campaign outreach port (zpro-client, templates, outreach routes/UI). Copied in as-is, untouched, so this feature branch has a working baseline; the original uncommitted state in the main checkout is left exactly as it was.
206 lines
8.6 KiB
JavaScript
206 lines
8.6 KiB
JavaScript
import { getSetting, listMessageTemplates, updateMessageTemplate } from '../../db.mjs';
|
|
import { createZproClient, zproConfigured, zproDiagnostics } from '../../outreach/zpro-client.mjs';
|
|
import { listWabaTemplateLibrary, syncWabaTemplateLibrary } from '../../outreach/waba-template-library.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 = db.prepare(
|
|
"SELECT COUNT(*) AS t FROM outreach_sends WHERE status = 'enviado' AND date(sent_at) = date('now')"
|
|
).get().t;
|
|
|
|
return { limite, enviadosHoje, restante: Math.max(0, limite - enviadosHoje) };
|
|
}
|
|
|
|
export function registerOutreachRoutes(router, { db }) {
|
|
router.add('GET', '/api/outreach/status', async (req, res, { json }) => {
|
|
const ativo = getSetting(db, 'outreach.ativo', 'false') === 'true';
|
|
const { limite, enviadosHoje, restante } = dailyLimitStatus(db);
|
|
|
|
const porProduto = db.prepare(
|
|
`SELECT produto, COUNT(*) AS total
|
|
FROM outreach_sends
|
|
WHERE status = 'enviado' AND date(sent_at) = date('now')
|
|
GROUP BY produto ORDER BY total DESC`
|
|
).all();
|
|
|
|
// LEFT JOIN (nao JOIN): envio manual nao tem lead_id, senao sumiria da lista.
|
|
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,
|
|
configurado: zproConfigured(db),
|
|
mensagensPorDia: limite,
|
|
enviadosHoje,
|
|
restante,
|
|
porProduto,
|
|
ultimos
|
|
});
|
|
});
|
|
|
|
// Testa as credenciais Z-PRO atuais (settings ou .env) com uma chamada real
|
|
// e barata (listTags), sem mandar nenhuma mensagem — pra validar a tela de
|
|
// Credenciais Z-PRO antes de ativar a automacao de verdade.
|
|
router.add('POST', '/api/outreach/testar-conexao', async (req, res, { json }) => {
|
|
if (!zproConfigured(db)) {
|
|
json(400, { ok: false, erro: 'preencha Base URL, API ID e Token antes de testar', diagnostico: zproDiagnostics(db) });
|
|
return;
|
|
}
|
|
try {
|
|
await createZproClient(db).listTags({ isActive: true });
|
|
json(200, { ok: true, diagnostico: zproDiagnostics(db) });
|
|
} catch (error) {
|
|
json(200, { ok: false, erro: String(error.message || error), diagnostico: zproDiagnostics(db) });
|
|
}
|
|
});
|
|
|
|
// Envio avulso pra um numero especifico, fora do lote automatico e sem
|
|
// depender de um lead cadastrado — util pra mandar uma mensagem pontual
|
|
// ou testar um template antes de ligar a automacao pra valer. Fica
|
|
// registrado em outreach_sends com origem='manual' (lead_id/produto
|
|
// ficam vazios), aparece nos "ultimos" igual aos envios automaticos.
|
|
router.add('POST', '/api/outreach/enviar-manual', async (req, res, { json, body }) => {
|
|
const numeroDigitado = String(body?.numero || '').trim();
|
|
if (!numeroDigitado) {
|
|
json(400, { erro: 'informe o numero (com DDI+DDD, ex.: 5511999999999)' });
|
|
return;
|
|
}
|
|
// O Z-PRO aceita (200 OK) numero mal formatado e so falha depois, de forma
|
|
// assincrona, sem avisar o app — por isso valida aqui ANTES de mandar,
|
|
// em vez de confiar na resposta da API pra saber se o numero e valido.
|
|
const { valid, motivo } = validatePhone(numeroDigitado);
|
|
if (!valid) {
|
|
json(400, { erro: `numero invalido (${motivo}): use DDI+DDD+numero, so digitos, ex. 5511999999999` });
|
|
return;
|
|
}
|
|
const numero = onlyDigits(numeroDigitado);
|
|
const canal = body?.canal === 'oficial' ? 'oficial' : 'nao_oficial';
|
|
const mensagem = String(body?.mensagem || '').trim();
|
|
const templateWabaNome = String(body?.templateWabaNome || '').trim();
|
|
const templateWabaIdioma = String(body?.templateWabaIdioma || 'pt_BR').trim() || 'pt_BR';
|
|
const templateRoute = String(body?.templateRota || 'template').trim() || 'template';
|
|
const rawTemplateVariables = Array.isArray(body?.templateVariaveis)
|
|
? body.templateVariaveis.map((value) => String(value || '').trim()).filter(Boolean)
|
|
: [String(body?.templateVariavel1 || '').trim()].filter(Boolean);
|
|
const leadContext = body?.lead && typeof body.lead === 'object' ? body.lead : {};
|
|
const templateVariables = rawTemplateVariables.map((value) => renderTemplate(value, {
|
|
empresa: 'Gustavo',
|
|
produto: 'Atendimento comercial',
|
|
cidade: 'Salvador',
|
|
segmento_label: 'Suporte',
|
|
email: 'cliente@exemplo.com.br',
|
|
...leadContext,
|
|
whatsapp: leadContext.whatsapp || onlyDigits(numeroDigitado)
|
|
}));
|
|
|
|
if (canal === 'nao_oficial' && !mensagem) {
|
|
json(400, { erro: 'escreva a mensagem pro canal nao-oficial' });
|
|
return;
|
|
}
|
|
if (canal === 'oficial' && !templateWabaNome) {
|
|
json(400, { erro: 'informe o nome do template aprovado pro canal oficial' });
|
|
return;
|
|
}
|
|
|
|
const { limite, enviadosHoje, restante } = dailyLimitStatus(db);
|
|
if (restante <= 0) {
|
|
json(429, {
|
|
ok: false,
|
|
erro: `limite diario atingido (${enviadosHoje}/${limite}). Aumente o limite por dia ou aguarde virar o dia.`,
|
|
limite,
|
|
enviadosHoje,
|
|
restante
|
|
});
|
|
return;
|
|
}
|
|
|
|
const insert = db.prepare(
|
|
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, erro, zpro_ticket_id, canal, origem)
|
|
VALUES (NULL, '', ?, ?, ?, ?, ?, ?, 'manual')`
|
|
);
|
|
const mensagemEnviada = canal === 'oficial' ? `template:${templateWabaNome}/${templateWabaIdioma}` : mensagem;
|
|
|
|
try {
|
|
const zproClient = createZproClient(db);
|
|
const externalKey = `gti-manual-${Date.now()}`;
|
|
const resposta = canal === 'oficial'
|
|
? await zproClient.sendTemplateWaba({
|
|
number: numero,
|
|
templateName: templateWabaNome,
|
|
languageCode: templateWabaIdioma,
|
|
route: templateRoute,
|
|
variables: templateVariables
|
|
})
|
|
: await zproClient.sendText({ number: numero, message: mensagem, externalKey });
|
|
const ticketId = resposta?.ticketId || resposta?.data?.ticketId ? String(resposta.ticketId || resposta.data.ticketId) : null;
|
|
insert.run(numero, mensagemEnviada, 'enviado', null, ticketId, canal);
|
|
json(200, { ok: true, ticketId });
|
|
} catch (error) {
|
|
insert.run(numero, mensagemEnviada, 'erro', String(error.message || error), null, canal);
|
|
json(200, { ok: false, erro: String(error.message || error) });
|
|
}
|
|
});
|
|
|
|
router.add('GET', '/api/outreach/templates', async (req, res, { json }) => {
|
|
json(200, { templates: listMessageTemplates(db) });
|
|
});
|
|
|
|
router.add('GET', '/api/outreach/waba-templates', async (req, res, { json }) => {
|
|
const syncUrl = getSetting(db, 'gchat.templatesUrl', '').trim();
|
|
const lastSync = getSetting(db, 'gchat.templatesLastSync', '');
|
|
const lastSyncAgeMs = lastSync ? Date.now() - new Date(lastSync).getTime() : Infinity;
|
|
if (syncUrl && lastSyncAgeMs > 10 * 60 * 1000) {
|
|
try {
|
|
await syncWabaTemplateLibrary(db);
|
|
} catch {
|
|
// A biblioteca local continua funcionando mesmo se o GChat estiver fora.
|
|
}
|
|
}
|
|
json(200, {
|
|
templates: listWabaTemplateLibrary(db),
|
|
lastSync: getSetting(db, 'gchat.templatesLastSync', '')
|
|
});
|
|
});
|
|
|
|
router.add('GET', '/api/outreach/variables', async (req, res, { json }) => {
|
|
json(200, { variables: COMMERCIAL_VARIABLES });
|
|
});
|
|
|
|
router.add('POST', '/api/outreach/waba-templates/sync', async (req, res, { json }) => {
|
|
try {
|
|
json(200, await syncWabaTemplateLibrary(db));
|
|
} catch (error) {
|
|
json(200, { ok: false, erro: String(error.message || error) });
|
|
}
|
|
});
|
|
|
|
router.add('PUT', '/api/outreach/templates/:produto', async (req, res, { json, params, body }) => {
|
|
const produto = params.produto;
|
|
if (!body || typeof body !== 'object') {
|
|
json(400, { erro: 'corpo JSON esperado: {"mensagem": "...", "ativo": true}' });
|
|
return;
|
|
}
|
|
|
|
const atualizado = updateMessageTemplate(db, produto, {
|
|
mensagem: body.mensagem,
|
|
ativo: body.ativo,
|
|
canal: body.canal,
|
|
templateWabaNome: body.templateWabaNome,
|
|
templateWabaIdioma: body.templateWabaIdioma,
|
|
templateWabaVariaveis: body.templateWabaVariaveis
|
|
});
|
|
if (!atualizado) {
|
|
json(404, { erro: `template do produto "${produto}" nao encontrado` });
|
|
return;
|
|
}
|
|
json(200, atualizado);
|
|
});
|
|
}
|