feat: implement autonomous messaging schedules and fix portal grade crash

This commit is contained in:
Sidney 2026-04-30 11:30:14 -03:00
parent 74216f170d
commit a8adcd6cf0
7 changed files with 485 additions and 76 deletions

View File

@ -37,3 +37,5 @@
15. **Retake Policy**: Students ARE allowed to retake activities and exams. The system MUST delete the previous submission and overwrite the grade in `school_data.json` to ensure only the latest attempt is valid.
16. **Financial Categories**: The system supports categories: `monthly` (Mensalidade), `registration` (Matrícula), `handout` (Apostila), and `others` (Outros). Automated forms must map references (Cursos -> monthly, Registration -> registration, Handout -> handout).
17. **Modal Floating Principle**: All system modals must avoid backdrop-blur and background overlays. Use `bg-transparent` for the fixed container and `bg-white` (solid) for the modal box, ensuring contrast via large soft shadows (`shadow-2xl` or equivalent).
18. **Automated Messaging (Cron Jobs)**: The system uses `node-cron` for independent message scheduling (Preventive vs. Overdue). Overdue logic MUST implement safety checks using `overdue_warnings_count` and `last_overdue_warning_at` to avoid spamming the student. Immediate webhook triggers for `PAYMENT_OVERDUE` are disabled in favor of scheduled routines.
19. **Numerical Data Integrity**: When retrieving data from PostgreSQL `NUMERIC` or `DECIMAL` columns, values MUST be explicitly cast to `Number()` in the backend before being sent to the frontend to prevent crashes when using `.toFixed()` in React.

View File

@ -1,6 +1,17 @@
# MEMORY.md - Contexto de Desenvolvimento
## 📅 Estado Atual (22/04/2026)
## 📅 Estado Atual (30/04/2026)
- [x] **Automação de Mensagens (Cron Jobs):** Implementados dois disparadores independentes (`preventivo` e `atrasado`) via `node-cron`.
- [x] **Persistência de Agendamento:** Configurações de horário e ativação salvas no `school_data` e restauradas automaticamente no boot do servidor.
- [x] **Monitoramento em Tempo Real:** Indicadores visuais (bolinha pulsante) no card de mensagens que refletem o status real do Job no servidor.
- [x] **Cobrança Inteligente (Inadimplência):** Refatorada lógica para respeitar `sendDaysAfter` (carência) e `repeatEveryDays` (intervalo), evitando spam diário.
- [x] **Segurança Anti-Spam:** Desativado envio imediato de `PAYMENT_OVERDUE` via Webhook para garantir que cobranças ocorram apenas no horário agendado.
- [x] **Auto-Initialization DB:** Script de boot que garante a existência das colunas `overdue_warnings_count` e `last_overdue_warning_at` na tabela `alunos_cobrancas`.
- [x] **Correção de Crash no Portal:** Resolvido erro de `.toFixed()` que quebrava as abas de "Avaliações" e "Notas" devido ao retorno de tipos `NUMERIC` do PostgreSQL como strings.
- [ ] Próximo Passo: Monitorar o log de disparos automáticos (`[Cron]`) e validar a taxa de entrega via Evolution API.
## 📅 Histórico Anterior (22/04/2026)
- [x] Correção do "Bug da Tela Preta" na câmera ao alternar para câmera traseira no celular.
- [x] Unificação do servidor de produção: Dockerfile agora utiliza `server.selfhosted.js` (Manager e Portal).

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { SchoolData } from '../types';
import { useDialog } from '../DialogContext';
import { MessageSquare, Save, Info, Settings, Send, Clock, AlertTriangle, FileText, CheckCircle, Cake, X } from 'lucide-react';
import { MessageSquare, Save, Info, Settings, Send, Clock, AlertTriangle, FileText, CheckCircle, Cake, X, Power } from 'lucide-react';
interface MessagesProps {
data: SchoolData;
@ -39,7 +39,27 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
}
});
const [isSending, setIsSending] = useState(false);
const [isSendingPreventive, setIsSendingPreventive] = useState(false);
const [isSendingOverdue, setIsSendingOverdue] = useState(false);
// Estado do Agendamento Automático - Preventivo
const [scheduleEnabled, setScheduleEnabled] = useState(!!initRules.autoScheduleEnabled);
const [scheduleTime, setScheduleTime] = useState(initRules.autoScheduleTime || '09:00');
const [isSavingSchedule, setIsSavingSchedule] = useState(false);
const [cronActive, setCronActive] = useState(false);
// Estado do Agendamento Automático - Inadimplência
const [scheduleOverdueEnabled, setScheduleOverdueEnabled] = useState(!!initRules.autoScheduleOverdueEnabled);
const [scheduleOverdueTime, setScheduleOverdueTime] = useState(initRules.autoScheduleOverdueTime || '10:00');
const [isSavingScheduleOverdue, setIsSavingScheduleOverdue] = useState(false);
const [cronOverdueActive, setCronOverdueActive] = useState(false);
useEffect(() => {
fetch('/api/cron/status').then(r => r.json()).then(d => {
setCronActive(d.preventive);
setCronOverdueActive(d.overdue);
}).catch(() => {});
}, []);
// Estados WhatsApp em Massa
const [targetType, setTargetType] = useState('todos');
@ -132,7 +152,7 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
'Disparar Cobranças',
'Tem certeza que deseja processar e enviar as mensagens para TODOS os alunos com pagamentos ATRASADOS agora?',
async () => {
setIsSending(true);
setIsSendingOverdue(true);
try {
const resp = await fetch('/api/disparar_cobrancas?tipo=atrasado', { method: 'POST' });
const resData = await resp.json();
@ -144,7 +164,7 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
} catch (e: any) {
showAlert('Erro', 'Erro de conexão ao disparar cobranças.', 'error');
} finally {
setIsSending(false);
setIsSendingOverdue(false);
}
}
);
@ -155,7 +175,7 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
'Lembretes Preventivos',
'Tem certeza que deseja iniciar o envio dos LEMBRETES PREVENTIVOS para os boletos próximos do vencimento agora?',
async () => {
setIsSending(true);
setIsSendingPreventive(true);
try {
const resp = await fetch('/api/disparar_cobrancas?tipo=preventivo', { method: 'POST' });
const resData = await resp.json();
@ -167,7 +187,7 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
} catch (e: any) {
showAlert('Erro', 'Erro de conexão.', 'error');
} finally {
setIsSending(false);
setIsSendingPreventive(false);
}
}
);
@ -364,28 +384,203 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
</p>
<button
onClick={handleDispararPreventivos}
disabled={isSending || !data.evolutionConfig?.apiUrl}
disabled={isSendingPreventive || !data.evolutionConfig?.apiUrl}
className={`w-full py-3.5 px-4 rounded-xl font-black text-sm text-white shadow-lg transition-all active:scale-95 ${
isSending || !data.evolutionConfig?.apiUrl ? 'bg-slate-400' : 'bg-indigo-600 hover:bg-indigo-700'
isSendingPreventive || !data.evolutionConfig?.apiUrl ? 'bg-slate-400' : 'bg-indigo-600 hover:bg-indigo-700'
}`}
>
{isSending ? 'Processando...' : 'Enviar Lembretes Agora'}
{isSendingPreventive ? 'Processando...' : 'Enviar Lembretes Agora'}
</button>
{/* Agendamento Automático */}
<div className="mt-5 pt-5 border-t border-indigo-200">
<div className="flex items-center justify-between mb-4">
<label className="text-[10px] font-black text-indigo-700 uppercase tracking-widest flex items-center gap-1.5">
<Power size={13} /> Rotina Automática
</label>
<button
onClick={async () => {
const newEnabled = !scheduleEnabled;
setScheduleEnabled(newEnabled);
setIsSavingSchedule(true);
try {
const resp = await fetch('/api/cron/schedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: newEnabled, time: scheduleTime, tipo: 'preventivo' })
});
const d = await resp.json();
setCronActive(d.preventive);
showAlert('Sucesso', newEnabled ? `Rotina ativada para ${scheduleTime}!` : 'Rotina automática desativada.', 'success');
} catch {
showAlert('Erro', 'Erro ao salvar agendamento.', 'error');
setScheduleEnabled(!newEnabled);
} finally {
setIsSavingSchedule(false);
}
}}
disabled={isSavingSchedule}
className={`relative w-12 h-6 rounded-full transition-all duration-300 ${
scheduleEnabled ? 'bg-indigo-600' : 'bg-slate-300'
}`}
>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow-md transition-transform duration-300 ${
scheduleEnabled ? 'translate-x-6' : 'translate-x-0'
}`} />
</button>
</div>
{scheduleEnabled && (
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
<div>
<label className="block text-[10px] font-black text-indigo-500 uppercase tracking-widest mb-1.5 ml-1">Horário do Disparo</label>
<div className="flex gap-2">
<input
type="time"
value={scheduleTime}
onChange={(e) => setScheduleTime(e.target.value)}
className="flex-1 px-4 py-2.5 border border-indigo-200 rounded-xl text-sm font-bold text-center bg-white focus:ring-2 focus:ring-indigo-500 focus:outline-none shadow-sm"
/>
<button
onClick={async () => {
setIsSavingSchedule(true);
try {
const resp = await fetch('/api/cron/schedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, time: scheduleTime, tipo: 'preventivo' })
});
const d = await resp.json();
setCronActive(d.preventive);
showAlert('Sucesso', `Horário atualizado para ${scheduleTime}!`, 'success');
} catch {
showAlert('Erro', 'Erro ao atualizar horário.', 'error');
} finally {
setIsSavingSchedule(false);
}
}}
disabled={isSavingSchedule}
className="px-4 py-2.5 bg-indigo-600 text-white rounded-xl font-black text-xs hover:bg-indigo-700 transition-all active:scale-95 shadow-md"
>
{isSavingSchedule ? '...' : 'Salvar'}
</button>
</div>
</div>
<div className={`flex items-center gap-2 text-[10px] font-bold px-3 py-2 rounded-lg ${
cronActive ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'
}`}>
<span className={`w-2 h-2 rounded-full ${
cronActive ? 'bg-emerald-500 animate-pulse' : 'bg-slate-400'
}`} />
{cronActive ? `Ativo — Próximo disparo às ${scheduleTime}` : 'Inativo'}
</div>
</div>
)}
</div>
</div>
<div className="bg-amber-50 border border-amber-200 p-6 rounded-2xl shadow-lg">
<h3 className="font-black text-amber-800 flex items-center gap-2 mb-3 text-sm uppercase tracking-widest">
<AlertTriangle size={18} /> Inadimplência
</h3>
<p className="text-[10px] text-amber-600 font-medium mb-4">
Envia cobranças para boletos com status atrasado.
</p>
<button
onClick={handleDispararCobrancas}
disabled={isSending || !data.evolutionConfig?.apiUrl}
disabled={isSendingOverdue || !data.evolutionConfig?.apiUrl}
className={`w-full py-3.5 px-4 rounded-xl font-black text-sm text-white shadow-lg transition-all active:scale-95 ${
isSending || !data.evolutionConfig?.apiUrl ? 'bg-slate-400' : 'bg-amber-500 hover:bg-amber-600'
isSendingOverdue || !data.evolutionConfig?.apiUrl ? 'bg-slate-400' : 'bg-amber-500 hover:bg-amber-600'
}`}
>
{isSending ? 'Processando...' : 'Disparar Cobranças Now'}
{isSendingOverdue ? 'Processando...' : 'Disparar Cobranças Agora'}
</button>
{/* Agendamento Automático - Inadimplência */}
<div className="mt-5 pt-5 border-t border-amber-200">
<div className="flex items-center justify-between mb-4">
<label className="text-[10px] font-black text-amber-700 uppercase tracking-widest flex items-center gap-1.5">
<Power size={13} /> Rotina Automática
</label>
<button
onClick={async () => {
const newEnabled = !scheduleOverdueEnabled;
setScheduleOverdueEnabled(newEnabled);
setIsSavingScheduleOverdue(true);
try {
const resp = await fetch('/api/cron/schedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: newEnabled, time: scheduleOverdueTime, tipo: 'atrasado' })
});
const d = await resp.json();
setCronOverdueActive(d.overdue);
showAlert('Sucesso', newEnabled ? `Rotina de inadimplência ativada para ${scheduleOverdueTime}!` : 'Rotina de inadimplência desativada.', 'success');
} catch {
showAlert('Erro', 'Erro ao salvar agendamento.', 'error');
setScheduleOverdueEnabled(!newEnabled);
} finally {
setIsSavingScheduleOverdue(false);
}
}}
disabled={isSavingScheduleOverdue}
className={`relative w-12 h-6 rounded-full transition-all duration-300 ${
scheduleOverdueEnabled ? 'bg-amber-500' : 'bg-slate-300'
}`}
>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow-md transition-transform duration-300 ${
scheduleOverdueEnabled ? 'translate-x-6' : 'translate-x-0'
}`} />
</button>
</div>
{scheduleOverdueEnabled && (
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
<div>
<label className="block text-[10px] font-black text-amber-500 uppercase tracking-widest mb-1.5 ml-1">Horário do Disparo</label>
<div className="flex gap-2">
<input
type="time"
value={scheduleOverdueTime}
onChange={(e) => setScheduleOverdueTime(e.target.value)}
className="flex-1 px-4 py-2.5 border border-amber-200 rounded-xl text-sm font-bold text-center bg-white focus:ring-2 focus:ring-amber-500 focus:outline-none shadow-sm"
/>
<button
onClick={async () => {
setIsSavingScheduleOverdue(true);
try {
const resp = await fetch('/api/cron/schedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, time: scheduleOverdueTime, tipo: 'atrasado' })
});
const d = await resp.json();
setCronOverdueActive(d.overdue);
showAlert('Sucesso', `Horário atualizado para ${scheduleOverdueTime}!`, 'success');
} catch {
showAlert('Erro', 'Erro ao atualizar horário.', 'error');
} finally {
setIsSavingScheduleOverdue(false);
}
}}
disabled={isSavingScheduleOverdue}
className="px-4 py-2.5 bg-amber-500 text-white rounded-xl font-black text-xs hover:bg-amber-600 transition-all active:scale-95 shadow-md"
>
{isSavingScheduleOverdue ? '...' : 'Salvar'}
</button>
</div>
</div>
<div className={`flex items-center gap-2 text-[10px] font-bold px-3 py-2 rounded-lg ${
cronOverdueActive ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'
}`}>
<span className={`w-2 h-2 rounded-full ${
cronOverdueActive ? 'bg-emerald-500 animate-pulse' : 'bg-slate-400'
}`} />
{cronOverdueActive ? `Ativo — Próximo disparo às ${scheduleOverdueTime}` : 'Inativo'}
</div>
</div>
)}
</div>
</div>
<div className="bg-pink-50 border border-pink-200 p-6 rounded-2xl shadow-lg">

View File

@ -21,6 +21,7 @@
"jspdf-autotable": "^3.8.2",
"lucide-react": "^0.563.0",
"multer": "^2.1.1",
"node-cron": "^4.2.1",
"pg": "^8.20.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@ -5391,6 +5392,15 @@
"node": ">= 0.6"
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",

View File

@ -24,6 +24,7 @@
"jspdf-autotable": "^3.8.2",
"lucide-react": "^0.563.0",
"multer": "^2.1.1",
"node-cron": "^4.2.1",
"pg": "^8.20.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",

View File

@ -18,13 +18,14 @@ import { fileURLToPath } from 'url';
import multer from 'multer';
import sharp from 'sharp';
import jwt from 'jsonwebtoken';
import cron from 'node-cron';
// === Novos módulos Self-Hosted (substituem Supabase) ===
import {
getSchoolData, saveSchoolData, pool,
insertCobrancas, updateCobranca, deleteCobranca,
getCobrancaByPaymentId, getCobrancasByOrQuery,
getCobrancasByAlunoId, getCobrancasAtrasadas,
getCobrancasByAlunoId, getCobrancasAtrasadas, getCobrancasPendentes,
getCobrancasByInstallmentId, updateCobrancaLinkCarne,
updateCobrancaByField
} from './services/database.js';
@ -48,6 +49,8 @@ app.use(cors());
const cancelCache = new Set();
const sentCache = new Set();
const lockCache = new Set();
let activeCronJob = null; // Referência global para o agendamento preventivo
let activeCronJobOverdue = null; // Referência global para o agendamento de inadimplência
// ============================================================
// Proxy de Imagens do MinIO (acesso público via backend)
@ -182,6 +185,12 @@ app.put('/api/school-data', async (req, res) => {
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='last_pre_warning_at') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN last_pre_warning_at TIMESTAMP WITH TIME ZONE;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='overdue_warnings_count') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN overdue_warnings_count INTEGER DEFAULT 0;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='last_overdue_warning_at') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN last_overdue_warning_at TIMESTAMP WITH TIME ZONE;
END IF;
END $$;
`).catch(err => console.error('[PostgreSQL] Erro ao inicializar colunas de automação:', err));
@ -601,8 +610,9 @@ app.post('/api/webhook_asaas', async (req, res) => {
const statusMap = { 'PENDING': 'PENDENTE', 'OVERDUE': 'ATRASADO', 'RECEIVED': 'PAGO', 'CONFIRMED': 'PAGO', 'RECEIVED_IN_CASH': 'PAGO', 'REFUNDED': 'CANCELADO', 'DELETED': 'CANCELADO' };
updateData = { valor: payload.payment.value, vencimento: payload.payment.dueDate, status: statusMap[payload.payment.status] || undefined };
Object.keys(updateData).forEach(k => updateData[k] === undefined && delete updateData[k]);
if (payload.event === 'PAYMENT_OVERDUE') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_OVERDUE');
else if (payload.event === 'PAYMENT_UPDATED') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_UPDATED');
// Ocultado PAYMENT_OVERDUE aqui para ser enviado apenas pela rotina/cron (conforme regras)
// if (payload.event === 'PAYMENT_OVERDUE') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_OVERDUE');
if (payload.event === 'PAYMENT_UPDATED') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_UPDATED');
break;
case 'PAYMENT_DELETED':
@ -907,70 +917,196 @@ app.get('/api/alunos/:id/carne', async (req, res) => {
// ============================================================
// INICIALIZAÇÃO
// ============================================================
// ============================================================
// LÓGICA REUTILIZÁVEL DE DISPARO DE COBRANÇAS
// ============================================================
async function executarRotinaCobrancas(tipo = 'ambos') {
const appData = await getSchoolData();
const rules = appData?.messageTemplates?.automationRules || {};
const sendDaysBefore = parseInt(rules.sendDaysBefore) || 3;
const maxPreWarnings = parseInt(rules.maxPreWarnings) || 1;
const sendDaysAfter = parseInt(rules.sendDaysAfter) || 1;
const repeatEveryDays = parseInt(rules.repeatEveryDays) || 3;
let enviadasAtraso = 0;
let enviadasAviso = 0;
// 1. Processar Atrasados
if (tipo === 'atrasado' || tipo === 'ambos') {
const atrasados = await getCobrancasAtrasadas();
const hoje = new Date();
hoje.setHours(0,0,0,0);
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 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 diasDesdeUltimoAviso = lastWarn
? Math.floor((hoje.getTime() - lastWarn.getTime()) / (1000 * 60 * 60 * 24))
: null;
const jaEnviadoHoje = lastWarn && lastWarn.getTime() === hoje.getTime();
if (!jaEnviadoHoje && (diasDesdeUltimoAviso === null || diasDesdeUltimoAviso >= repeatEveryDays)) {
await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_OVERDUE');
const currentCount = parseInt(cob.overdue_warnings_count) || 0;
await pool.query(
'UPDATE alunos_cobrancas SET overdue_warnings_count = $1, last_overdue_warning_at = NOW() WHERE asaas_payment_id = $2',
[currentCount + 1, cob.asaas_payment_id]
);
enviadasAtraso++;
}
}
}
}
// 2. Processar A Vencer (Lembretes Preventivos)
if (tipo === 'preventivo' || tipo === 'ambos') {
const pendentes = await getCobrancasPendentes();
const hoje = new Date();
hoje.setHours(0,0,0,0);
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 diffDias = Math.ceil((vencimento.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24));
if (diffDias > 0 && diffDias <= sendDaysBefore) {
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();
if (!jaEnviadoHoje) {
await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_UPCOMING');
await pool.query(
'UPDATE alunos_cobrancas SET pre_warnings_count = $1, last_pre_warning_at = NOW() WHERE asaas_payment_id = $2',
[currentCount + 1, cob.asaas_payment_id]
);
enviadasAviso++;
}
}
}
}
}
return { enviadasAtraso, enviadasAviso };
}
// ============================================================
// AGENDADOR AUTOMÁTICO (node-cron) — Suporte a múltiplos tipos
// ============================================================
function agendarRotina(tipo, hora, minuto) {
const isPreventivo = tipo === 'preventivo';
const label = isPreventivo ? 'Preventivo' : 'Inadimplência';
// Cancela job anterior do mesmo tipo
if (isPreventivo && activeCronJob) {
activeCronJob.stop();
activeCronJob = null;
console.log(`[Cron:${label}] ⏹ Rotina anterior cancelada.`);
} else if (!isPreventivo && activeCronJobOverdue) {
activeCronJobOverdue.stop();
activeCronJobOverdue = null;
console.log(`[Cron:${label}] ⏹ Rotina anterior cancelada.`);
}
const h = parseInt(hora);
const m = parseInt(minuto);
if (isNaN(h) || isNaN(m) || h < 0 || h > 23 || m < 0 || m > 59) {
console.error(`[Cron:${label}] Horário inválido:`, hora, minuto);
return;
}
const cronTipo = isPreventivo ? 'preventivo' : 'atrasado';
const cronExpression = `${m} ${h} * * *`;
const job = cron.schedule(cronExpression, async () => {
console.log(`[Cron:${label}] ⏰ Rotina automática iniciada às ${new Date().toLocaleTimeString('pt-BR')}`);
try {
const resultado = await executarRotinaCobrancas(cronTipo);
const count = isPreventivo ? resultado.enviadasAviso : resultado.enviadasAtraso;
console.log(`[Cron:${label}] ✅ Concluído: ${count} mensagens processadas.`);
} catch (error) {
console.error(`[Cron:${label}] ❌ Erro na rotina automática:`, error.message);
}
}, { timezone: 'America/Sao_Paulo' });
if (isPreventivo) activeCronJob = job;
else activeCronJobOverdue = job;
console.log(`[Cron:${label}] ✅ Rotina agendada para ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')} (America/Sao_Paulo)`);
}
async function inicializarAgendamento() {
try {
// Inicialização DB para colunas de automação (garantir no boot)
await pool.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='pre_warnings_count') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN pre_warnings_count INTEGER DEFAULT 0;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='last_pre_warning_at') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN last_pre_warning_at TIMESTAMP WITH TIME ZONE;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='overdue_warnings_count') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN overdue_warnings_count INTEGER DEFAULT 0;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='last_overdue_warning_at') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN last_overdue_warning_at TIMESTAMP WITH TIME ZONE;
END IF;
END $$;
`).catch(err => console.error('[PostgreSQL] Erro boot automação:', err));
const appData = await getSchoolData();
const rules = appData?.messageTemplates?.automationRules || {};
// Preventivo
if (rules.autoScheduleEnabled && rules.autoScheduleTime) {
const [h, m] = rules.autoScheduleTime.split(':');
agendarRotina('preventivo', h, m);
} else {
console.log('[Cron:Preventivo] Agendamento desativado.');
}
// Inadimplência
if (rules.autoScheduleOverdueEnabled && rules.autoScheduleOverdueTime) {
const [h, m] = rules.autoScheduleOverdueTime.split(':');
agendarRotina('atrasado', h, m);
} else {
console.log('[Cron:Inadimplência] Agendamento desativado.');
}
} catch (e) {
console.error('[Cron] Erro ao inicializar agendamento:', e.message);
}
}
async function startServer() {
// Disparo Manual de Inadimplência e Lembretes
app.post('/api/disparar_cobrancas', async (req, res) => {
try {
const tipo = req.query.tipo || 'ambos';
const appData = await getSchoolData();
const rules = appData?.messageTemplates?.automationRules || {};
const sendDaysBefore = parseInt(rules.sendDaysBefore) || 3;
const maxPreWarnings = parseInt(rules.maxPreWarnings) || 1;
let enviadasAtraso = 0;
let enviadasAviso = 0;
// 1. Processar Atrasados
if (tipo === 'atrasado' || tipo === 'ambos') {
const atrasados = await getCobrancasAtrasadas();
for (const cob of atrasados) {
if (cob.asaas_payment_id) {
await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_OVERDUE');
enviadasAtraso++;
}
}
}
// 2. Processar A Vencer (Lembretes Preventivos)
if (tipo === 'preventivo' || tipo === 'ambos') {
const pendentes = await getCobrancasPendentes();
const hoje = new Date();
hoje.setHours(0,0,0,0);
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 diffDias = Math.ceil((vencimento.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24));
if (diffDias > 0 && diffDias <= sendDaysBefore) {
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();
if (!jaEnviadoHoje) {
await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_UPCOMING');
await pool.query(
'UPDATE alunos_cobrancas SET pre_warnings_count = $1, last_pre_warning_at = NOW() WHERE asaas_payment_id = $2',
[currentCount + 1, cob.asaas_payment_id]
);
enviadasAviso++;
}
}
}
}
}
const resultado = await executarRotinaCobrancas(tipo);
let msg = '';
if (tipo === 'atrasado') msg = `${enviadasAtraso} mensagens de atraso processadas.`;
else if (tipo === 'preventivo') msg = `${enviadasAviso} lembretes preventivos processados.`;
else msg = `${enviadasAtraso} mensagens de atraso e ${enviadasAviso} lembretes preventivos processados.`;
if (tipo === 'atrasado') msg = `${resultado.enviadasAtraso} mensagens de atraso processadas.`;
else if (tipo === 'preventivo') msg = `${resultado.enviadasAviso} lembretes preventivos processados.`;
else msg = `${resultado.enviadasAtraso} mensagens de atraso e ${resultado.enviadasAviso} lembretes preventivos processados.`;
return res.status(200).json({ message: msg });
} catch (error) {
@ -979,6 +1115,55 @@ async function startServer() {
}
});
// API para gerenciar o agendamento (suporte a preventivo e atrasado)
app.get('/api/cron/status', (req, res) => {
res.json({
preventive: !!activeCronJob,
overdue: !!activeCronJobOverdue
});
});
app.post('/api/cron/schedule', async (req, res) => {
try {
const { enabled, time, tipo } = req.body;
const isOverdue = tipo === 'atrasado';
const appData = await getSchoolData();
if (!appData.messageTemplates) appData.messageTemplates = {};
if (!appData.messageTemplates.automationRules) appData.messageTemplates.automationRules = {};
if (isOverdue) {
appData.messageTemplates.automationRules.autoScheduleOverdueEnabled = !!enabled;
appData.messageTemplates.automationRules.autoScheduleOverdueTime = time || '09:00';
} else {
appData.messageTemplates.automationRules.autoScheduleEnabled = !!enabled;
appData.messageTemplates.automationRules.autoScheduleTime = time || '09:00';
}
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
if (enabled && time) {
const [h, m] = time.split(':');
agendarRotina(isOverdue ? 'atrasado' : 'preventivo', h, m);
} else {
if (isOverdue) {
if (activeCronJobOverdue) { activeCronJobOverdue.stop(); activeCronJobOverdue = null; }
} else {
if (activeCronJob) { activeCronJob.stop(); activeCronJob = null; }
}
}
res.json({
success: true,
preventive: !!activeCronJob,
overdue: !!activeCronJobOverdue
});
} catch (error) {
console.error('[Cron] Erro ao salvar agendamento:', error);
res.status(500).json({ error: 'Erro interno.' });
}
});
// Imprimir Carnê
app.get('/api/imprimir-carne/:installmentId', async (req, res) => {
try {
@ -1039,7 +1224,11 @@ async function startServer() {
}
}
app.listen(PORT, '0.0.0.0', () => console.log(`🚀 EduManager Self-Hosted na porta ${PORT}`));
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 EduManager Self-Hosted na porta ${PORT}`);
// Inicializa agendamento automático após servidor subir
inicializarAgendamento();
});
}
startServer();

View File

@ -514,14 +514,15 @@ app.get('/api/portal/avaliacoes', authMiddleware, async (req, res) => {
);
// Mapear nomes de colunas do banco para o formato esperado pelo frontend
// IMPORTANTE: NUMERIC(5,2) retorna como string do pg, precisa de Number()
const mappedSubmissions = (submissions || []).map(s => ({
...s,
exam_id: s.prova_id || s.exam_id,
total_questions: s.total_questoes || s.total_questions,
correct_count: s.acertos || s.correct_count,
wrong_count: s.erros || s.wrong_count,
percentage: s.percentual || s.percentage,
final_score: s.nota_final || s.final_score,
percentage: Number(s.percentual || s.percentage || 0),
final_score: Number(s.nota_final || s.final_score || 0),
answers_json: s.respostas || s.answers_json,
}));