diff --git a/GEMINI.md b/GEMINI.md
index 10372d1..49f0674 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -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.
diff --git a/MEMORY.md b/MEMORY.md
index 32ad156..1c0cdef 100644
--- a/MEMORY.md
+++ b/MEMORY.md
@@ -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).
diff --git a/manager/components/Messages.tsx b/manager/components/Messages.tsx
index 825e189..8b43444 100644
--- a/manager/components/Messages.tsx
+++ b/manager/components/Messages.tsx
@@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ data, updateData }) => {
- {isSending ? 'Processando...' : 'Enviar Lembretes Agora'}
+ {isSendingPreventive ? 'Processando...' : 'Enviar Lembretes Agora'}
+
+ {/* Agendamento Automático */}
+
+
+
+ Rotina Automática
+
+
{
+ 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'
+ }`}
+ >
+
+
+
+
+ {scheduleEnabled && (
+
+
+
Horário do Disparo
+
+ 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"
+ />
+ {
+ 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'}
+
+
+
+
+
+ {cronActive ? `Ativo — Próximo disparo às ${scheduleTime}` : 'Inativo'}
+
+
+ )}
+
Inadimplência
+
+ Envia cobranças para boletos com status atrasado.
+
- {isSending ? 'Processando...' : 'Disparar Cobranças Now'}
+ {isSendingOverdue ? 'Processando...' : 'Disparar Cobranças Agora'}
+
+ {/* Agendamento Automático - Inadimplência */}
+
+
+
+ Rotina Automática
+
+
{
+ 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'
+ }`}
+ >
+
+
+
+
+ {scheduleOverdueEnabled && (
+
+
+
Horário do Disparo
+
+ 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"
+ />
+ {
+ 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'}
+
+
+
+
+
+ {cronOverdueActive ? `Ativo — Próximo disparo às ${scheduleOverdueTime}` : 'Inativo'}
+
+
+ )}
+
diff --git a/manager/package-lock.json b/manager/package-lock.json
index d8b174f..ad24598 100644
--- a/manager/package-lock.json
+++ b/manager/package-lock.json
@@ -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",
diff --git a/manager/package.json b/manager/package.json
index 27c0743..199f4df 100644
--- a/manager/package.json
+++ b/manager/package.json
@@ -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",
diff --git a/manager/server.selfhosted.js b/manager/server.selfhosted.js
index a4a2eeb..3830d2b 100644
--- a/manager/server.selfhosted.js
+++ b/manager/server.selfhosted.js
@@ -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();
diff --git a/portal/server.selfhosted.js b/portal/server.selfhosted.js
index ec6791b..1f56619 100644
--- a/portal/server.selfhosted.js
+++ b/portal/server.selfhosted.js
@@ -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,
}));