feat: implement autonomous messaging schedules and fix portal grade crash
This commit is contained in:
parent
74216f170d
commit
a8adcd6cf0
|
|
@ -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.
|
||||
|
|
|
|||
13
MEMORY.md
13
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).
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,16 +917,16 @@ app.get('/api/alunos/:id/carne', async (req, res) => {
|
|||
// ============================================================
|
||||
// INICIALIZAÇÃO
|
||||
// ============================================================
|
||||
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';
|
||||
// ============================================================
|
||||
// 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;
|
||||
|
|
@ -924,13 +934,40 @@ async function startServer() {
|
|||
// 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) {
|
||||
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') {
|
||||
|
|
@ -967,10 +1004,109 @@ async function startServer() {
|
|||
}
|
||||
}
|
||||
|
||||
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 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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue