Foto automática, boleto na conversa e novo Dashboard
- Foto do cliente atualizada automaticamente ao abrir a conversa (remove botão manual) - Exibe CLI_FOTO na tela de Dados do Cliente; matrícula com conversão robusta - Envio de boleto na conversa (checkbox em Configurações > Empresa): lista títulos em aberto e envia texto com linha digitável + PIX ao cliente - Novo Dashboard (gerente): conversas (abertas/não atendidas/não atribuídas/pendentes), atendentes disponíveis/desconectados e heatmap de tráfego - Presença de atendentes em memória + ping periódico do chat/dashboard Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1167,6 +1167,144 @@ class ChatController {
|
||||
});
|
||||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||
}
|
||||
/**
|
||||
* Valida que o cliente informado pertence à mesma empresa da conversa
|
||||
* (e que o usuário tem acesso à conversa). Retorna { ok, clienteId } ou { status }.
|
||||
*/
|
||||
static async checarClienteDaConversa(alias, conversaId, clienteId, req) {
|
||||
const chk = await ChatController.checarConversaEmpresa(alias, conversaId, req);
|
||||
if (chk.status) return chk;
|
||||
const cid = parseInt(clienteId, 10);
|
||||
if (!cid) return { status: 400 };
|
||||
const cli = await db.query(alias,
|
||||
'SELECT CLI_EMPRESA_ID FROM CLIENTES WHERE CLI_CODIGO_ID = ?', [cid]);
|
||||
if (cli.length === 0) return { status: 404 };
|
||||
if (chk.empresaId && cli[0].CLI_EMPRESA_ID && cli[0].CLI_EMPRESA_ID !== chk.empresaId)
|
||||
return { status: 403 };
|
||||
return { ok: true, clienteId: cid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista títulos (boletos) em aberto do cliente vinculado à conversa,
|
||||
* para o atendente escolher qual enviar.
|
||||
* GET /api/:alias/conversations/:id/boletos?clienteId=
|
||||
*/
|
||||
static async getBoletos(req, res) {
|
||||
try {
|
||||
const { alias, id } = req.params;
|
||||
const chk = await ChatController.checarClienteDaConversa(alias, id, req.query.clienteId, req);
|
||||
if (chk.status) return res.status(chk.status).json({ success: false, error: chk.status === 403 ? 'Sem permissão.' : (chk.status === 404 ? 'Cliente não encontrado.' : 'Parâmetros inválidos.') });
|
||||
|
||||
const carnes = await db.query(alias,
|
||||
`SELECT CAR_CODIGO_ID, CAR_DT_VENCIMENTO, CAR_VALOR_PARCELA, CAR_LINHA_DIGITAVEL,
|
||||
CAR_NOSSO_NUMERO, CAR_PIX_QRCODE, CAR_NUMERO_PARCELA, CAR_NUMERO_TOTAL_PARCELAS,
|
||||
CAR_CODIGO_BARRAS
|
||||
FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 0
|
||||
ORDER BY CAR_DT_VENCIMENTO`, [chk.clienteId]);
|
||||
|
||||
const fmtDate = (dt) => {
|
||||
if (!dt) return null;
|
||||
if (typeof dt === 'string') return dt.split('T')[0];
|
||||
if (dt instanceof Date) return dt.toISOString().split('T')[0];
|
||||
return String(dt);
|
||||
};
|
||||
|
||||
res.json({ success: true, data: carnes.map(function(c) {
|
||||
return {
|
||||
id: c.CAR_CODIGO_ID,
|
||||
vencimento: fmtDate(c.CAR_DT_VENCIMENTO),
|
||||
valor: c.CAR_VALOR_PARCELA,
|
||||
parcela: c.CAR_NUMERO_PARCELA,
|
||||
totalParcelas: c.CAR_NUMERO_TOTAL_PARCELAS,
|
||||
temLinhaDigitavel: !!(c.CAR_LINHA_DIGITAVEL && String(c.CAR_LINHA_DIGITAVEL).trim()),
|
||||
temPix: !!(c.CAR_PIX_QRCODE && String(c.CAR_PIX_QRCODE).trim()),
|
||||
};
|
||||
}) });
|
||||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Envia um boleto escolhido (texto: linha digitável + PIX) ao cliente no chat.
|
||||
* POST /api/:alias/conversations/:id/send-boleto Body: { clienteId, carneId }
|
||||
*/
|
||||
static async sendBoleto(req, res) {
|
||||
try {
|
||||
const { alias, id } = req.params;
|
||||
const { clienteId, carneId } = req.body;
|
||||
|
||||
const chk = await ChatController.checarClienteDaConversa(alias, id, clienteId, req);
|
||||
if (chk.status) return res.status(chk.status).json({ success: false, error: chk.status === 403 ? 'Sem permissão.' : (chk.status === 404 ? 'Cliente não encontrado.' : 'Parâmetros inválidos.') });
|
||||
|
||||
const carneIdNum = parseInt(carneId, 10);
|
||||
if (!carneIdNum) return res.status(400).json({ success: false, error: 'Título inválido.' });
|
||||
|
||||
// Título precisa pertencer ao cliente validado (evita IDOR)
|
||||
const car = await db.query(alias,
|
||||
`SELECT CAR_CODIGO_ID, CAR_DT_VENCIMENTO, CAR_VALOR_PARCELA, CAR_LINHA_DIGITAVEL,
|
||||
CAR_PIX_QRCODE, CAR_NUMERO_PARCELA, CAR_NUMERO_TOTAL_PARCELAS, CAR_CODIGO_BARRAS
|
||||
FROM CARNES WHERE CAR_CODIGO_ID = ? AND CAR_CLIENTE_ID = ?`, [carneIdNum, chk.clienteId]);
|
||||
if (car.length === 0) return res.status(404).json({ success: false, error: 'Título não encontrado para este cliente.' });
|
||||
const b = car[0];
|
||||
|
||||
const conv = await db.query(alias, 'SELECT * FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?', [id]);
|
||||
if (conv.length === 0) return res.status(404).json({ success: false, error: 'Conversa não encontrada.' });
|
||||
|
||||
// Monta o texto do boleto
|
||||
const fmtDate = (dt) => {
|
||||
if (!dt) return '';
|
||||
let s = typeof dt === 'string' ? dt.split('T')[0] : (dt instanceof Date ? dt.toISOString().split('T')[0] : String(dt));
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
|
||||
return m ? (m[3] + '/' + m[2] + '/' + m[1]) : s;
|
||||
};
|
||||
const fmtValor = (v) => {
|
||||
const n = Number(v);
|
||||
if (isNaN(n)) return '';
|
||||
return 'R$ ' + n.toFixed(2).replace('.', ',');
|
||||
};
|
||||
const linha = (b.CAR_LINHA_DIGITAVEL || '').trim() || (b.CAR_CODIGO_BARRAS || '').trim();
|
||||
const pix = (b.CAR_PIX_QRCODE || '').trim();
|
||||
|
||||
let texto = '📄 *Boleto*';
|
||||
if (b.CAR_NUMERO_PARCELA) {
|
||||
texto += ' - Parcela ' + b.CAR_NUMERO_PARCELA + (b.CAR_NUMERO_TOTAL_PARCELAS ? '/' + b.CAR_NUMERO_TOTAL_PARCELAS : '');
|
||||
}
|
||||
texto += '\n';
|
||||
if (b.CAR_VALOR_PARCELA != null) texto += '\n💰 Valor: ' + fmtValor(b.CAR_VALOR_PARCELA);
|
||||
if (b.CAR_DT_VENCIMENTO) texto += '\n📅 Vencimento: ' + fmtDate(b.CAR_DT_VENCIMENTO);
|
||||
if (linha) texto += '\n\n*Linha digitável:*\n' + linha;
|
||||
if (pix) texto += '\n\n*PIX (copia e cola):*\n' + pix;
|
||||
|
||||
if (!linha && !pix) {
|
||||
return res.status(400).json({ success: false, error: 'Este título não possui linha digitável nem PIX disponível.' });
|
||||
}
|
||||
|
||||
// Salva mensagem do agente
|
||||
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
|
||||
const newId = (maxId[0]?.ID || 0) + 1;
|
||||
await db.execute(alias, `
|
||||
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_USUARIO_ID, CME_TEXTO, CME_TIPO, CME_PRIVADA, CME_DT_ENVIO)
|
||||
VALUES (?, ?, 'U', ?, ?, 'text', 'N', CURRENT_TIMESTAMP)
|
||||
`, [newId, id, req.user?.id, texto]);
|
||||
await db.execute(alias,
|
||||
'UPDATE CHATC2_CONVERSAS SET CON_DT_ULTIMA_MSG = CURRENT_TIMESTAMP WHERE CON_CODIGO_ID = ?', [id]);
|
||||
|
||||
// Envia via Evolution
|
||||
if (conv[0].CON_NUMERO) {
|
||||
try {
|
||||
const instancia = await db.query(alias,
|
||||
'SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ?', [conv[0].CON_INSTANCIA_ID]);
|
||||
if (instancia.length > 0) {
|
||||
await sendEvolutionMessage(instancia[0], conv[0].CON_NUMERO, texto, 'text', null);
|
||||
}
|
||||
} catch (evoErr) {
|
||||
console.error('[Boleto] Erro ao enviar via Evolution:', evoErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { id: newId } });
|
||||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Recebe avaliação CSAT via formulário web
|
||||
* POST /api/:alias/csat/avaliar
|
||||
|
||||
Reference in New Issue
Block a user