From a8adcd6cf0ef05853a56389cb1c436b5b1f8a585 Mon Sep 17 00:00:00 2001 From: Sidney Date: Thu, 30 Apr 2026 11:30:14 -0300 Subject: [PATCH] feat: implement autonomous messaging schedules and fix portal grade crash --- GEMINI.md | 2 + MEMORY.md | 13 +- manager/components/Messages.tsx | 221 +++++++++++++++++++++-- manager/package-lock.json | 10 ++ manager/package.json | 1 + manager/server.selfhosted.js | 309 +++++++++++++++++++++++++------- portal/server.selfhosted.js | 5 +- 7 files changed, 485 insertions(+), 76 deletions(-) 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 }) => {

+ + {/* Agendamento Automático */} +
+
+ + +
+ + {scheduleEnabled && ( +
+
+ +
+ 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" + /> + +
+
+
+ + {cronActive ? `Ativo — Próximo disparo às ${scheduleTime}` : 'Inativo'} +
+
+ )} +

Inadimplência

+

+ Envia cobranças para boletos com status atrasado. +

+ + {/* Agendamento Automático - Inadimplência */} +
+
+ + +
+ + {scheduleOverdueEnabled && ( +
+
+ +
+ 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" + /> + +
+
+
+ + {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, }));