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