From 161b074bf2f82ead39e88bad785d5598f6258b2f Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 8 May 2026 09:45:31 -0300 Subject: [PATCH] =?UTF-8?q?fix:=20estabiliza=C3=A7=C3=A3o=20final=20dos=20?= =?UTF-8?q?lembretes,=20corre=C3=A7=C3=A3o=20definitiva=20de=20timezone=20?= =?UTF-8?q?e=20ferramentas=20de=20debug=20para=20mensagens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MEMORY.md | 2 +- manager/components/Messages.tsx | 77 ++++++++++++++++++++++++++++----- manager/server.selfhosted.js | 61 ++++++++++++++++++++------ 3 files changed, 116 insertions(+), 24 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index b290a16..15f3a0a 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -107,7 +107,7 @@ - **Melhoria:** O build agora ocorre diretamente na arquitetura de destino, sem emulação QEMU, garantindo velocidade e estabilidade total. ### 📢 Automação de Mensagens -- [x] **Estabilização de Lembretes:** Sistema de avisos preventivos e de inadimplência agora é 100% confiável, com tracking preciso de envios e suporte a múltiplos templates dinâmicos. +- [x] **Estabilização de Lembretes (V2):** Resolvido bug de deslocamento de fuso horário (-1 dia) nas datas de vencimento. Implementadas ferramentas de debug (ignorar trava diária e reset de contadores) e **controle manual de delay para disparos em massa** para facilitar testes e garantir segurança contra banimento. ## 📋 Próximos Passos Pendentes diff --git a/manager/components/Messages.tsx b/manager/components/Messages.tsx index 44cd02b..a7e1a85 100644 --- a/manager/components/Messages.tsx +++ b/manager/components/Messages.tsx @@ -67,6 +67,7 @@ const Messages: React.FC = ({ data, updateData }) => { const [targetId, setTargetId] = useState(''); const [messageText, setMessageText] = useState(''); const [isSendingMass, setIsSendingMass] = useState(false); + const [massDelay, setMassDelay] = useState('60'); const [isSendingBdays, setIsSendingBdays] = useState(false); // Modal de Edição de Modelo @@ -237,7 +238,11 @@ const Messages: React.FC = ({ data, updateData }) => { const resp = await fetch('/api/enviar-massa', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ alunos: payloadAlunos, mensagem: normalizeLineBreaks(messageText) }) + body: JSON.stringify({ + alunos: payloadAlunos, + mensagem: normalizeLineBreaks(messageText), + delay: parseInt(massDelay) || 60 + }) }); const resData = await resp.json(); @@ -366,6 +371,21 @@ const Messages: React.FC = ({ data, updateData }) => { /> +
+ +
+ setMassDelay(e.target.value)} + className="w-full px-3 py-2 border border-emerald-200 rounded-lg text-sm font-bold text-center focus:ring-emerald-500 focus:outline-none" + /> +
seg/msg
+
+

Recomendado: 60s ou mais para evitar banimento.

+
+ +
+ + +
+ + + +
+
{/* Agendamento Automático */}
diff --git a/manager/server.selfhosted.js b/manager/server.selfhosted.js index e9c07da..66b55ae 100644 --- a/manager/server.selfhosted.js +++ b/manager/server.selfhosted.js @@ -937,13 +937,13 @@ app.put('/api/notificacoes/remover-anexo/:id', async (req, res) => { // Disparo em Massa // ============================================================ app.post('/api/enviar-massa', (req, res) => { - const { alunos, mensagem } = req.body; + const { alunos, mensagem, delay } = req.body; if (!alunos || !Array.isArray(alunos) || alunos.length === 0) return res.status(400).json({ error: 'Nenhum aluno.' }); res.status(200).json({ success: true, message: 'Background iniciado.' }); - processarFilaWhatsApp(alunos, mensagem); + processarFilaWhatsApp(alunos, mensagem, delay || 60); }); -async function processarFilaWhatsApp(alunos, mensagemTemplate) { +async function processarFilaWhatsApp(alunos, mensagemTemplate, customDelay = 60) { const appData = await getSchoolData(); const evoConfig = appData?.evolutionConfig; if (!evoConfig?.apiUrl || !evoConfig?.apiKey || !evoConfig?.instanceName) return; @@ -957,7 +957,11 @@ async function processarFilaWhatsApp(alunos, mensagemTemplate) { const url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/sendText/${evoConfig.instanceName}`; await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': evoConfig.apiKey }, body: JSON.stringify({ number: cleanPhone, text: msg }) }); } catch (error) { console.error(`[Massa] Erro ${aluno.nome}:`, error.message); } - if (i < alunos.length - 1) await new Promise(r => setTimeout(r, Math.floor(Math.random() * 120000) + 60000)); + if (i < alunos.length - 1) { + // Delay base informado pelo usuário + variância aleatória de 0-30s para evitar padrões robóticos + const delayMs = (customDelay * 1000) + (Math.floor(Math.random() * 30000)); + await new Promise(r => setTimeout(r, delayMs)); + } } } @@ -1103,6 +1107,27 @@ app.get('/api/alunos/:id/carne', async (req, res) => { // ============================================================ // ============================================================ // LÓGICA REUTILIZÁVEL DE DISPARO DE COBRANÇAS +// ============================================================ +// Helpers de Data +// ============================================================ +const getLocalSafeDate = (val) => { + if (!val) return null; + const d = new Date(val); + if (isNaN(d.getTime())) return null; + + // Se for string YYYY-MM-DD pura, o JS cria em UTC. + // Forçamos para os componentes locais para evitar o deslocamento de -1 dia. + if (typeof val === 'string' && val.includes('-') && !val.includes('T') && !val.includes(':')) { + const parts = val.split(' ')[0].split('-'); + if (parts.length === 3) { + return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0); + } + } + + // Para objetos Date ou strings com tempo, extraímos o dia civil local + return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0); +}; + // ============================================================ async function executarRotinaCobrancas(tipo = 'ambos') { const appData = await getSchoolData(); @@ -1124,19 +1149,19 @@ async function executarRotinaCobrancas(tipo = 'ambos') { for (const cob of atrasados) { if (!cob.asaas_payment_id || !cob.vencimento) continue; - const vencimento = new Date(cob.vencimento); - vencimento.setHours(0,0,0,0); + const vencimento = getLocalSafeDate(cob.vencimento); + if (!vencimento) continue; + const diffDiasAtraso = Math.floor((hoje.getTime() - vencimento.getTime()) / (1000 * 60 * 60 * 24)); if (diffDiasAtraso >= sendDaysAfter) { - const lastWarn = cob.last_overdue_warning_at ? new Date(cob.last_overdue_warning_at) : null; - if (lastWarn) lastWarn.setHours(0,0,0,0); + const lastWarn = getLocalSafeDate(cob.last_overdue_warning_at); const diasDesdeUltimoAviso = lastWarn ? Math.floor((hoje.getTime() - lastWarn.getTime()) / (1000 * 60 * 60 * 24)) : null; - const jaEnviadoHoje = lastWarn && lastWarn.getTime() === hoje.getTime(); + const jaEnviadoHoje = !rules.ignoreDailyLock && lastWarn && lastWarn.toDateString() === hoje.toDateString(); if (!jaEnviadoHoje && (diasDesdeUltimoAviso === null || diasDesdeUltimoAviso >= repeatEveryDays)) { const sent = await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_OVERDUE'); @@ -1164,8 +1189,8 @@ async function executarRotinaCobrancas(tipo = 'ambos') { for (const cob of pendentes) { if (!cob.asaas_payment_id || !cob.vencimento) continue; - const vencimento = new Date(cob.vencimento); - vencimento.setHours(0,0,0,0); + const vencimento = getLocalSafeDate(cob.vencimento); + if (!vencimento) continue; const diffDias = Math.ceil((vencimento.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24)); const sendOnDueDate = rules.sendOnDueDate !== false; @@ -1174,8 +1199,8 @@ async function executarRotinaCobrancas(tipo = 'ambos') { const currentCount = parseInt(cob.pre_warnings_count) || 0; if (currentCount < maxPreWarnings) { - const lastWarn = cob.last_pre_warning_at ? new Date(cob.last_pre_warning_at) : null; - const jaEnviadoHoje = lastWarn && lastWarn.toDateString() === hoje.toDateString(); + const lastWarn = getLocalSafeDate(cob.last_pre_warning_at); + const jaEnviadoHoje = !rules.ignoreDailyLock && lastWarn && lastWarn.toDateString() === hoje.toDateString(); if (!jaEnviadoHoje) { const sent = await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_UPCOMING'); @@ -1314,6 +1339,16 @@ async function inicializarAgendamento() { async function startServer() { + // Rota para zerar contadores de avisos + app.post('/api/admin/reset-cobrancas-counters', async (req, res) => { + try { + await pool.query('UPDATE alunos_cobrancas SET pre_warnings_count = 0, last_pre_warning_at = NULL, overdue_warnings_count = 0, last_overdue_warning_at = NULL'); + return res.json({ success: true, message: 'Contadores zerados com sucesso!' }); + } catch (e) { + return res.status(500).json({ error: e.message }); + } + }); + // Disparo Manual de Inadimplência e Lembretes app.post('/api/disparar_cobrancas', async (req, res) => { try {