diff --git a/src/controllers/chatController.js b/src/controllers/chatController.js index 22b943f..2350958 100644 --- a/src/controllers/chatController.js +++ b/src/controllers/chatController.js @@ -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 diff --git a/src/controllers/clientController.js b/src/controllers/clientController.js index cd15a22..78187bc 100644 --- a/src/controllers/clientController.js +++ b/src/controllers/clientController.js @@ -74,7 +74,8 @@ class ClientController { c.CLI_ENDERECO, c.CLI_NUMERO_FAT, c.CLI_BAIRRO_FAT, - c.CLI_CIDADES_ID + c.CLI_CIDADES_ID, + c.CLI_FOTO FROM CLIENTES c WHERE c.CLI_CODIGO_ID = ? AND c.CLI_EMPRESA_ID = ? @@ -133,6 +134,29 @@ class ClientController { // Interpreta a situação const situacao = client.CLI_SITUACAO?.trim() === 'A' ? 'Ativo' : 'Inativo'; + // Foto do cliente (CLI_FOTO) — pode vir como Buffer (bytea/blob) ou string base64 + let fotoCliente = null; + if (client.CLI_FOTO) { + const f = client.CLI_FOTO; + if (Buffer.isBuffer(f)) { + const s = f.toString('utf8'); + if (/^[A-Za-z0-9+/]+=*$/.test(s.slice(0, 30))) { + fotoCliente = s; + } else { + let prefixo = 'data:image/jpeg;base64,'; + if (f[0] === 0x89 && f[1] === 0x50) prefixo = 'data:image/png;base64,'; + else if (f[0] === 0x47 && f[1] === 0x49) prefixo = 'data:image/gif;base64,'; + else if (f[0] === 0x52 && f[1] === 0x49) prefixo = 'data:image/webp;base64,'; + fotoCliente = prefixo + f.toString('base64'); + } + } else { + fotoCliente = String(f); + } + } + + // Matrícula: converte com segurança (pode ser numérica ou string no banco) + const matriculaStr = client.CLI_MATRICULA == null ? '' : String(client.CLI_MATRICULA).trim(); + // Monta o endereço completo de faturamento const enderecoFaturamento = [ client.CLI_ENDERECO_FAT?.trim(), @@ -147,7 +171,8 @@ class ClientController { clienteId: id_cliente, data: { nome: client.CLI_NOME?.trim(), - matricula: client.CLI_MATRICULA?.trim(), + matricula: matriculaStr, + foto: fotoCliente, situacao: { codigo: client.CLI_SITUACAO?.trim(), descricao: situacao, diff --git a/src/controllers/configController.js b/src/controllers/configController.js index 3bf74f5..83ce33b 100644 --- a/src/controllers/configController.js +++ b/src/controllers/configController.js @@ -159,7 +159,7 @@ class ConfigController { 'SELECT * FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId] ); - const flagsPadrao = { motivoVisualizar: 'N', motivoObrigatorio: 'N', resolucaoVisualizar: 'N', resolucaoObrigatorio: 'N' }; + const flagsPadrao = { motivoVisualizar: 'N', motivoObrigatorio: 'N', resolucaoVisualizar: 'N', resolucaoObrigatorio: 'N', enviarBoleto: 'N' }; if (config.length === 0) { const cfg = Object.assign({ empresaId, fotoCelular: 'N', saudacaoAtiva: 'S', saudacaoMensagem: '', csatAtivo: 'N', csatMensagem: '', enviarNomeUsuario: 'N', triagemAtiva: 'N', triagemMsgWelcome: '', triagemMsgAfter: '', triagemBoletoNumero: '0', instanciaPadraoId: null }, flagsPadrao); @@ -179,6 +179,7 @@ class ConfigController { triagemMsgWelcome: c.CFE_TRIAGEM_MSG_WELCOME || '', triagemMsgAfter: c.CFE_TRIAGEM_MSG_AFTER || '', triagemBoletoNumero: (c.CFE_TRIAGEM_BOLETO_NUMERO || '0').trim(), + enviarBoleto: (c.CFE_ENVIAR_BOLETO || 'N').trim(), // Fluxo de Resolução motivoVisualizar: (c.CFE_MOTIVO_VISUALIZAR || 'N').trim(), motivoObrigatorio: (c.CFE_MOTIVO_OBRIGATORIO || 'N').trim(), @@ -261,8 +262,10 @@ class ConfigController { try { if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar as configurações da empresa.' }); const { alias } = req.params; + await garantirEstrutura(alias); const data = req.body; const empresaId = data.empresaId || req.user?.empresas?.[0]; + const enviarBoleto = (data.enviarBoleto === 'S' || data.enviarBoleto === true) ? 'S' : 'N'; // Upsert const exists = await db.query(alias, 'SELECT COUNT(*) AS T FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]); @@ -273,16 +276,16 @@ class ConfigController { CFE_INSTANCIA_PADRAO_ID = ?, CFE_FOTO_CELULAR = ?, CFE_SAUDACAO_ATIVA = ?, CFE_SAUDACAO_MENSAGEM = ?, CFE_CSAT_ATIVO = ?, CFE_CSAT_MENSAGEM = ?, CFE_ENVIAR_NOME_USUARIO = ?, CFE_TRIAGEM_ATIVA = ?, CFE_TRIAGEM_MSG_WELCOME = ?, - CFE_TRIAGEM_MSG_AFTER = ?, CFE_TRIAGEM_BOLETO_NUMERO = ? + CFE_TRIAGEM_MSG_AFTER = ?, CFE_TRIAGEM_BOLETO_NUMERO = ?, CFE_ENVIAR_BOLETO = ? WHERE CFE_EMPRESA_ID = ? - `, [data.instanciaPadraoId || null, data.fotoCelular || 'N', data.saudacaoAtiva || 'S', data.saudacaoMensagem || '', data.csatAtivo || 'N', data.csatMensagem || '', data.enviarNomeUsuario || 'N', data.triagemAtiva || 'N', data.triagemMsgWelcome || '', data.triagemMsgAfter || '', data.triagemBoletoNumero || '0', empresaId]); + `, [data.instanciaPadraoId || null, data.fotoCelular || 'N', data.saudacaoAtiva || 'S', data.saudacaoMensagem || '', data.csatAtivo || 'N', data.csatMensagem || '', data.enviarNomeUsuario || 'N', data.triagemAtiva || 'N', data.triagemMsgWelcome || '', data.triagemMsgAfter || '', data.triagemBoletoNumero || '0', enviarBoleto, empresaId]); } else { await db.execute(alias, ` INSERT INTO CHATC2_CONFIGURACOES_EMPRESA (CFE_EMPRESA_ID, CFE_INSTANCIA_PADRAO_ID, CFE_FOTO_CELULAR, CFE_SAUDACAO_ATIVA, CFE_SAUDACAO_MENSAGEM, CFE_CSAT_ATIVO, CFE_CSAT_MENSAGEM, CFE_ENVIAR_NOME_USUARIO, - CFE_TRIAGEM_ATIVA, CFE_TRIAGEM_MSG_WELCOME, CFE_TRIAGEM_MSG_AFTER, CFE_TRIAGEM_BOLETO_NUMERO) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, [empresaId, data.instanciaPadraoId || null, data.fotoCelular || 'N', data.saudacaoAtiva || 'S', data.saudacaoMensagem || '', data.csatAtivo || 'N', data.csatMensagem || '', data.enviarNomeUsuario || 'N', data.triagemAtiva || 'N', data.triagemMsgWelcome || '', data.triagemMsgAfter || '', data.triagemBoletoNumero || '0']); + CFE_TRIAGEM_ATIVA, CFE_TRIAGEM_MSG_WELCOME, CFE_TRIAGEM_MSG_AFTER, CFE_TRIAGEM_BOLETO_NUMERO, CFE_ENVIAR_BOLETO) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [empresaId, data.instanciaPadraoId || null, data.fotoCelular || 'N', data.saudacaoAtiva || 'S', data.saudacaoMensagem || '', data.csatAtivo || 'N', data.csatMensagem || '', data.enviarNomeUsuario || 'N', data.triagemAtiva || 'N', data.triagemMsgWelcome || '', data.triagemMsgAfter || '', data.triagemBoletoNumero || '0', enviarBoleto]); } res.json({ success: true }); diff --git a/src/controllers/dashboardController.js b/src/controllers/dashboardController.js new file mode 100644 index 0000000..2acf3f2 --- /dev/null +++ b/src/controllers/dashboardController.js @@ -0,0 +1,126 @@ +const db = require('../database'); +const { isGerente } = require('../middlewares/roles'); +const presence = require('../presence'); + +/** + * Resolve a lista de empresas para a consulta, respeitando a permissão do + * usuário. Se vier empresaId e o usuário tiver acesso, usa só ela; senão usa + * todas as empresas do usuário. + */ +function empresasPermitidas(req) { + const minhas = (req.user && req.user.empresas) || []; + const pedida = parseInt(req.query.empresaId, 10); + if (pedida && minhas.includes(pedida)) return [pedida]; + return minhas; +} + +class DashboardController { + /** + * Ping de presença — marca o atendente como online. + * POST /api/:alias/dashboard/ping + */ + static async ping(req, res) { + const { alias } = req.params; + if (req.user && req.user.id != null) presence.registrar(alias, req.user.id); + res.json({ success: true }); + } + + /** + * Estatísticas do dashboard (apenas gerente). + * GET /api/:alias/dashboard/stats?empresaId= + */ + static async stats(req, res) { + try { + if (!(await isGerente(req))) { + return res.status(403).json({ success: false, error: 'Apenas gerentes podem visualizar o dashboard.' }); + } + const { alias } = req.params; + const empresas = empresasPermitidas(req); + if (empresas.length === 0) { + return res.json({ success: true, data: { + conversas: { abertas: 0, naoAtendidas: 0, naoAtribuidas: 0, pendentes: 0 }, + atendentes: { disponiveis: 0, desconectados: 0, lista: [] }, + trafego: [], + } }); + } + const ph = empresas.map(() => '?').join(','); + + // ===== Conversas ===== + // Abertas: têm atendente E equipe + const abertas = await db.query(alias, + `SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS + WHERE CON_EMPRESA_ID IN (${ph}) AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A' + AND CON_USUARIO_ID IS NOT NULL AND CON_EQUIPE_ID IS NOT NULL`, empresas); + + // Não atendidas: têm atendente+equipe e a última mensagem foi do cliente ('C') + const naoAtendidas = await db.query(alias, + `SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS c + WHERE c.CON_EMPRESA_ID IN (${ph}) AND c.CON_STATUS IN ('A','E') AND c.CON_SITUACAO = 'A' + AND c.CON_USUARIO_ID IS NOT NULL AND c.CON_EQUIPE_ID IS NOT NULL + AND (SELECT m.CME_REMETENTE FROM CHATC2_CONVERSAS_MENSAGENS m + WHERE m.CME_CONVERSA_ID = c.CON_CODIGO_ID + ORDER BY m.CME_DT_ENVIO DESC FETCH FIRST 1 ROWS ONLY) = 'C'`, empresas); + + // Não atribuídas: sem atendente e sem equipe + const naoAtribuidas = await db.query(alias, + `SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS + WHERE CON_EMPRESA_ID IN (${ph}) AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A' + AND CON_USUARIO_ID IS NULL AND CON_EQUIPE_ID IS NULL`, empresas); + + // Pendentes: têm apenas equipe (sem atendente) + const pendentes = await db.query(alias, + `SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS + WHERE CON_EMPRESA_ID IN (${ph}) AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A' + AND CON_USUARIO_ID IS NULL AND CON_EQUIPE_ID IS NOT NULL`, empresas); + + // ===== Atendentes ===== + const usuarios = await db.query(alias, + `SELECT DISTINCT u.USU_CODIGO_ID, u.USU_NOME + FROM USUARIOS u INNER JOIN USUARIOS_EMPRESA ue ON u.USU_CODIGO_ID = ue.USE_USUARIO_ID + WHERE ue.USE_EMPRESA_ID IN (${ph}) AND u.USU_STATUS = 'A' AND COALESCE(u.USU_ACESSO_WEB,0) = 1 + ORDER BY u.USU_NOME`, empresas); + + const online = presence.idsOnline(alias); + let disponiveis = 0, desconectados = 0; + const lista = usuarios.map(function (u) { + const on = online.has(Number(u.USU_CODIGO_ID)); + if (on) disponiveis++; else desconectados++; + return { id: u.USU_CODIGO_ID, nome: (u.USU_NOME || '').trim(), online: on }; + }); + + // ===== Tráfego das conversas (últimos 365 dias, por dia) ===== + const desde = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); + const desdeStr = desde.toISOString().split('T')[0]; + const trafegoRows = await db.query(alias, + `SELECT CAST(COALESCE(CON_DT_INICIO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG) AS DATE) AS DIA, COUNT(*) AS CT + FROM CHATC2_CONVERSAS + WHERE CON_EMPRESA_ID IN (${ph}) + AND COALESCE(CON_DT_INICIO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG) >= ? + GROUP BY CAST(COALESCE(CON_DT_INICIO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG) AS DATE)`, + empresas.concat([desdeStr])); + + const fmtDia = (d) => { + if (!d) return null; + if (typeof d === 'string') return d.split('T')[0]; + if (d instanceof Date) return d.toISOString().split('T')[0]; + return String(d); + }; + const trafego = trafegoRows + .map(function (r) { return { dia: fmtDia(r.DIA), total: Number(r.CT) || 0 }; }) + .filter(function (r) { return r.dia; }); + + res.json({ success: true, data: { + conversas: { + abertas: Number(abertas[0]?.CT) || 0, + naoAtendidas: Number(naoAtendidas[0]?.CT) || 0, + naoAtribuidas: Number(naoAtribuidas[0]?.CT) || 0, + pendentes: Number(pendentes[0]?.CT) || 0, + }, + atendentes: { disponiveis, desconectados, lista }, + trafego, + } }); + } catch (err) { res.status(500).json({ success: false, error: err.message }); } + } +} + +module.exports = DashboardController; diff --git a/src/presence.js b/src/presence.js new file mode 100644 index 0000000..8870e47 --- /dev/null +++ b/src/presence.js @@ -0,0 +1,56 @@ +/** + * Presença de atendentes (em memória). + * + * Mantém um mapa simples de "última atividade" por (alias + usuário). As páginas + * autenticadas (chat, dashboard) enviam um ping periódico; consideramos + * "disponível" quem teve atividade nos últimos TTL_MS milissegundos. + * + * É em memória de propósito: não altera o schema do banco e funciona bem para + * um único processo Node. Em cluster/multi-processo seria preciso um store + * compartilhado (Redis), mas isso foge do escopo atual. + */ + +const TTL_MS = 2 * 60 * 1000; // 2 minutos sem ping => desconectado + +// chave: alias + '|' + usuarioId => timestamp (ms) do último ping +const ultimoPing = new Map(); + +function chave(alias, usuarioId) { + return String(alias) + '|' + String(usuarioId); +} + +/** Registra atividade do usuário. */ +function registrar(alias, usuarioId) { + if (!alias || usuarioId == null) return; + ultimoPing.set(chave(alias, usuarioId), Date.now()); +} + +/** Retorna true se o usuário teve atividade recente (online). */ +function estaOnline(alias, usuarioId) { + const t = ultimoPing.get(chave(alias, usuarioId)); + return !!t && (Date.now() - t) <= TTL_MS; +} + +/** Conjunto (Set) de IDs de usuários online para um alias. */ +function idsOnline(alias) { + const agora = Date.now(); + const prefixo = String(alias) + '|'; + const set = new Set(); + for (const [k, t] of ultimoPing.entries()) { + if (k.startsWith(prefixo) && (agora - t) <= TTL_MS) { + set.add(Number(k.slice(prefixo.length))); + } + } + return set; +} + +// Limpeza periódica de entradas expiradas (evita crescimento indefinido) +const intervalo = setInterval(function () { + const agora = Date.now(); + for (const [k, t] of ultimoPing.entries()) { + if (agora - t > TTL_MS * 5) ultimoPing.delete(k); + } +}, TTL_MS); +if (intervalo.unref) intervalo.unref(); + +module.exports = { registrar, estaOnline, idsOnline, TTL_MS }; diff --git a/src/public/chat.html b/src/public/chat.html index 5375136..09fba3f 100644 --- a/src/public/chat.html +++ b/src/public/chat.html @@ -651,6 +651,18 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode') + +
+