edumanagerpro2/manager/server.selfhosted.js

4078 lines
159 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ============================================================
* EDUMANAGER — SERVER SELF-HOSTED
* ============================================================
* SUBSTITUIÇÃO CIRÚRGICA:
* - @supabase/supabase-js → pg (PostgreSQL direto)
* - Supabase Storage → MinIO (S3-compatible)
*
* TODAS AS ROTAS mantêm a mesma assinatura e resposta.
* O frontend NÃO percebe a diferença.
* ============================================================
*/
import express from 'express';
import cors from 'cors';
import { jsPDF } from 'jspdf';
import 'jspdf-autotable';
// fetch nativo do Node 22 será utilizado automaticamente
import fs from 'fs';
import { fileURLToPath } from 'url';
import multer from 'multer';
import sharp from 'sharp';
import jwt from 'jsonwebtoken';
import cron from 'node-cron';
import path from 'path';
import crypto from 'crypto';
// === Novos módulos Self-Hosted (substituem Supabase) ===
import {
getSchoolData, saveSchoolData, pool,
insertCobrancas, updateCobranca, deleteCobranca,
getCobrancaByPaymentId, getCobrancasByOrQuery,
getCobrancasByAlunoId, getCobrancasAtrasadas, getCobrancasPendentes,
getCobrancasByInstallmentId, updateCobrancaLinkCarne,
updateCobrancaByField,
initNotasTable, getNotasByAluno, upsertNota,
syncJsonToRelationalTables,
getFuncionarios, getCategoriasFuncionarios,
insertFuncionario, updateFuncionario, deleteFuncionario,
insertCategoriaFuncionario, updateCategoriaFuncionario, deleteCategoriaFuncionario,
getCursos, insertCurso, updateCurso, deleteCurso,
getTurmas, insertTurma, updateTurma, deleteTurma,
getDisciplinas, insertDisciplina, updateDisciplina, deleteDisciplina,
getAlunos, insertAluno, updateAluno, deleteAluno,
getModelosContrato, insertModeloContrato, updateModeloContrato, deleteModeloContrato,
getContratos, insertContrato, updateContrato, deleteContrato,
getAulasByTurma, getAllAulas, insertAulas, deleteAulas,
getFrequencias, insertFrequencia, updateFrequencia, deleteFrequencia,
getProvas, getQuestoesDaProva, insertProva, updateProva, deleteProva, syncQuestoesProva,
getConfiguracoes, insertConfiguracao, updateConfiguracao, deleteConfiguracao
} from './services/database.js';
import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage, uploadReceipt as uploadReceiptToStorage, getMinioStats, s3Client, getBucketObjects, deleteMinioObject, uploadFile, getFileBuffer } from './services/storage.js';
import { GetObjectCommand } from '@aws-sdk/client-s3';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
const JWT_SECRET = process.env.JWT_SECRET || 'EduManager-JWT-Secret-2026!';
// === ASAAS: URL base dinâmica inteligente ===
const ASAAS_KEY = process.env.ASAAS_API_KEY || '';
const ASAAS_BASE_URL = process.env.ASAAS_API_URL || (ASAAS_KEY.startsWith('$a') ? 'https://api.asaas.com' : 'https://sandbox.asaas.com/api');
app.use(express.json({ limit: '50mb' }));
app.use(cors());
const cancelCache = new Set();
// === Gerador da Página Pública de Pré-Matrícula ===
function getPreMatriculaHTML(slug) {
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Pré-Matrícula Online</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',sans-serif;background:#f8fafc;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}
.container{max-width:640px;width:100%;background:#fff;border-radius:24px;box-shadow:0 25px 50px -12px rgba(0,0,0,.1);overflow:hidden}
.header{padding:40px 32px 24px;text-align:center}
.header img{max-height:80px;max-width:240px;margin:0 auto 24px;display:block;object-fit:contain}
.header h1{font-size:28px;font-weight:900;color:#0f172a;margin-bottom:8px}
.header p{font-size:14px;color:#64748b;line-height:1.6}
.form{padding:0 32px 32px}
.field{margin-bottom:20px}
.field label{display:block;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px}
.field label .req{color:#ef4444;margin-left:2px}
.field input,.field select,.field textarea{width:100%;padding:14px 16px;background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:12px;font-size:14px;font-family:'Inter',sans-serif;font-weight:500;color:#0f172a;transition:.2s;outline:none}
.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--primary,#4f46e5);box-shadow:0 0 0 3px rgba(79,70,229,.1)}
.field textarea{resize:vertical;min-height:80px}
.btn{display:block;width:100%;padding:16px;background:var(--primary,#4f46e5);color:#fff;border:none;border-radius:14px;font-size:15px;font-weight:800;cursor:pointer;transition:.2s;text-transform:uppercase;letter-spacing:1px}
.btn:hover{opacity:.9;transform:translateY(-1px)}
.btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
.success{text-align:center;padding:60px 32px}
.success .icon{width:64px;height:64px;background:#d1fae5;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 20px;font-size:28px}
.success h2{font-size:22px;font-weight:800;color:#0f172a;margin-bottom:8px}
.success p{color:#64748b;font-size:14px;line-height:1.6}
.error-page{text-align:center;padding:80px 32px}
.error-page h2{font-size:22px;font-weight:800;color:#0f172a;margin-bottom:8px}
.error-page p{color:#94a3b8;font-size:14px}
.loading{text-align:center;padding:80px 32px;color:#94a3b8}
.top-bar{height:5px;width:100%;background:var(--primary,#4f46e5)}
.toast{position:fixed;top:24px;left:50%;transform:translateX(-50%) translateY(-120px);max-width:480px;width:calc(100% - 40px);padding:16px 20px;border-radius:16px;font-size:14px;font-weight:600;color:#fff;box-shadow:0 20px 40px rgba(0,0,0,.15);z-index:9999;display:flex;align-items:center;gap:12px;transition:transform .4s cubic-bezier(.16,1,.3,1),opacity .4s;opacity:0}
.toast.show{transform:translateX(-50%) translateY(0);opacity:1}
.toast.error{background:linear-gradient(135deg,#ef4444,#dc2626)}
.toast.warning{background:linear-gradient(135deg,#f59e0b,#d97706)}
.toast .toast-icon{font-size:20px;flex-shrink:0}
.toast .toast-msg{flex:1;line-height:1.5}
.toast .toast-close{background:rgba(255,255,255,.2);border:none;color:#fff;width:28px;height:28px;border-radius:8px;cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .2s}
.toast .toast-close:hover{background:rgba(255,255,255,.35)}
</style>
</head>
<body>
<div class="container" id="app"><div class="loading">Carregando formulário...</div></div>
<script>
const slug='${slug}';
const app=document.getElementById('app');
function showToast(msg, type){
type = type || 'error';
var existing = document.querySelector('.toast');
if(existing) existing.remove();
var icon = type === 'error' ? '⚠️' : '⚠️';
var t = document.createElement('div');
t.className = 'toast ' + type;
t.innerHTML = '<span class="toast-icon">' + icon + '</span><span class="toast-msg">' + msg + '</span><button class="toast-close" onclick="this.parentElement.remove()">✕</button>';
document.body.appendChild(t);
setTimeout(function(){ t.classList.add('show'); }, 10);
setTimeout(function(){ t.classList.remove('show'); setTimeout(function(){ t.remove(); }, 400); }, 5000);
}
function maskPhone(v){
if(!v)return "";
v=v.replace(/[^0-9]/g,"");
if(v.length>11) v=v.substring(0,11);
if(v.length>10){
return '(' + v.substring(0,2) + ') ' + v.substring(2,7) + '-' + v.substring(7);
} else if(v.length>6){
return '(' + v.substring(0,2) + ') ' + v.substring(2,6) + '-' + v.substring(6);
} else if(v.length>2){
return '(' + v.substring(0,2) + ') ' + v.substring(2);
}
return v;
}
function maskCPF(v){
if(!v)return "";
v=v.replace(/[^0-9]/g,"");
if(v.length>11) v=v.substring(0,11);
if(v.length>9){
return v.substring(0,3) + '.' + v.substring(3,6) + '.' + v.substring(6,9) + '-' + v.substring(9);
} else if(v.length>6){
return v.substring(0,3) + '.' + v.substring(3,6) + '.' + v.substring(6);
} else if(v.length>3){
return v.substring(0,3) + '.' + v.substring(3);
}
return v;
}
function maskCEP(v){
if(!v)return "";
v=v.replace(/[^0-9]/g,"");
if(v.length>8) v=v.substring(0,8);
if(v.length>5){
return v.substring(0,5) + '-' + v.substring(5);
}
return v;
}
// Global single-scope event listener (Rule 50 - anti-double-bind)
var _maskingActive = false;
document.addEventListener('input',function(e){
if(_maskingActive) return;
if(!e.target)return;
var dt=e.target.getAttribute('data-type');
if(dt==='phone' || dt==='cpf' || dt==='cep'){
_maskingActive = true;
var raw = e.target.value;
var masked = raw;
if(dt==='phone') masked = maskPhone(raw);
else if(dt==='cpf') masked = maskCPF(raw);
else if(dt==='cep') masked = maskCEP(raw);
if (masked !== raw) {
var cursorPos = e.target.selectionStart || 0;
var diff = masked.length - raw.length;
e.target.value = masked;
try {
var newPos = Math.max(0, cursorPos + diff);
e.target.setSelectionRange(newPos, newPos);
} catch(err) {}
}
_maskingActive = false;
}
});
async function init(){
try{
const res=await fetch('/api/prematricula/public/'+slug);
if(!res.ok){app.innerHTML='<div class="error-page"><h2>Formulário indisponível</h2><p>Este formulário não está publicado ou o link é inválido.</p></div>';return}
const data=await res.json();
if(data.escola && data.escola.logo) {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = function() {
try {
const canvas = document.createElement('canvas');
const size = Math.min(img.width, img.height);
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(img, (img.width - size) / 2, (img.height - size) / 2, size, size, 0, 0, size, size);
let link = document.querySelector("link[rel~='icon']");
if (!link) {
link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'icon';
document.getElementsByTagName('head')[0].appendChild(link);
}
link.href = canvas.toDataURL('image/png');
}
} catch (e) {
let link = document.querySelector("link[rel~='icon']");
if (!link) {
link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'icon';
document.getElementsByTagName('head')[0].appendChild(link);
}
link.href = data.escola.logo;
}
};
img.onerror = function() {
let link = document.querySelector("link[rel~='icon']");
if (!link) {
link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'icon';
document.getElementsByTagName('head')[0].appendChild(link);
}
link.href = data.escola.logo;
};
img.src = data.escola.logo;
}
if(data.draft){
app.innerHTML='<div class="error-page"><h2>Todas as turmas estão lotadas</h2><p style="margin-top:12px;font-weight:600;color:#64748b">Em breve abriremos novas vagas. Fique atento!</p></div>';
return;
}
const c=data.config;
document.documentElement.style.setProperty('--primary',c.corPrimaria||'#4f46e5');
let html='<div class="top-bar"></div><div class="header">';
if(data.escola.logo)html+='<img src="'+data.escola.logo+'" alt="Logo">';
html+='<h1>'+c.titulo+'</h1><p>'+c.descricao+'</p></div>';
html+='<form class="form" id="preForm" onsubmit="return submitForm(event)">';
// Render fields dynamically in their exact database order
data.campos.forEach(f=>{
if(f.tipo==='banner'){
if(f.placeholder){
html+='<div style="text-align:center;margin-bottom:20px"><img src="'+f.placeholder+'" alt="Promoção" style="width:100%;height:auto;border-radius:16px;box-shadow:0 4px 12px rgba(0,0,0,.05);display:block;margin:0 auto"></div>';
}
}else{
html+='<div class="field"><label>'+f.label+(f.obrigatorio?' <span class="req">*</span>':'')+'</label>';
if(f.id==='_nome'){
html+='<input name="_nome"'+(f.obrigatorio?' required':'')+' placeholder="'+(f.placeholder||'Seu nome completo')+'">';
}else if(f.id==='_email'){
html+='<input name="_email" type="email" placeholder="'+(f.placeholder||'seu@email.com')+'">';
}else if(f.id==='_telefone'){
html+='<input name="_telefone" data-type="phone" placeholder="'+(f.placeholder||'(00) 00000-0000')+'" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">';
}else if(f.id==='_turma'){
if(data.turmas.length>0){
html+='<select name="_turma"'+(f.obrigatorio?' required':'')+'><option value="">Selecione a turma...</option>';
data.turmas.forEach(t=>{
const info=t.horario?' ('+t.horario+')':'';
const label=t.esgotado?t.nome+info+' [ESGOTADO]':t.nome+info;
const disabled=t.esgotado?' disabled':'';
html+='<option value="'+t.id+'" data-nome="'+t.nome+'"'+disabled+'>'+label+'</option>';
});
html+='</select>';
}else{
html+='<p style="color:#ef4444;font-size:12px;font-weight:600">Nenhuma turma disponível.</p>';
}
}else{
// Custom fields
const isTextarea = f.tipo === 'textarea' || (f.label && /observa/i.test(f.label));
let tipoDetectado = f.tipo;
if (f.label) {
const lbl = f.label.toLowerCase();
if (lbl.includes('cpf')) {
tipoDetectado = 'cpf';
} else if (lbl.includes('telefone') || lbl.includes('whatsapp') || lbl.includes('celular') || lbl.includes('phone')) {
tipoDetectado = 'phone';
} else if (lbl.includes('cep')) {
tipoDetectado = 'cep';
}
}
if(f.tipo==='file'){
html+='<input type="file" name="'+f.id+'"'+(f.obrigatorio?' required':'')+' accept="image/*,application/pdf">';
}else if(f.tipo==='select'){
html+='<select name="'+f.id+'"'+(f.obrigatorio?' required':'')+'><option value="">Selecione...</option>';
(f.opcoes||[]).forEach(o=>{html+='<option value="'+o+'">'+o+'</option>'});
html+='</select>';
}else if(isTextarea){
html+='<textarea name="'+f.id+'"'+(f.obrigatorio?' required':'')+' placeholder="'+(f.placeholder||'')+'"></textarea>';
}else{
let t=tipoDetectado;
if(t==='phone'||t==='cpf'||t==='cep')t='text';
let dt=(tipoDetectado==='phone'||tipoDetectado==='cpf'||tipoDetectado==='cep')?(' data-type="'+tipoDetectado+'" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"'):'';
html+='<input type="'+t+'" name="'+f.id+'"'+(f.obrigatorio?' required':'')+dt+' placeholder="'+(f.placeholder||'')+'">';
}
}
html+='</div>';
}
});
html+='<button type="submit" class="btn" id="submitBtn">Enviar Pré-Matrícula</button></form>';
app.innerHTML=html;
// Format any prefilled inputs on load
app.querySelectorAll('[data-type]').forEach(function(el){
const dt = el.getAttribute('data-type');
if (el.value) {
if(dt==='phone') el.value = maskPhone(el.value);
if(dt==='cpf') el.value = maskCPF(el.value);
if(dt==='cep') el.value = maskCEP(el.value);
}
});
window._successMsg=c.mensagemSucesso||'Pré-matrícula enviada com sucesso!';
}catch(e){app.innerHTML='<div class="error-page"><h2>Erro</h2><p>Não foi possível carregar o formulário.</p></div>'}
}
async function submitForm(e){
e.preventDefault();
const form=document.getElementById('preForm');
const btn=document.getElementById('submitBtn');
btn.disabled=true;btn.textContent='Enviando...';
const fd=new FormData(form);
const nome=fd.get('_nome'),email=fd.get('_email'),telefone=fd.get('_telefone');
const turmaSelect=form.querySelector('[name="_turma"]');
const turmaId=turmaSelect?turmaSelect.value:'';
const turmaNome=turmaSelect?turmaSelect.options[turmaSelect.selectedIndex]?.dataset?.nome||'':'';
const respostas={};
// Upload file inputs to MinIO first
const fileInputs = form.querySelectorAll('input[type="file"]');
for (const input of fileInputs) {
const file = input.files[0];
if (file) {
btn.textContent = 'Enviando arquivo...';
const fileFd = new FormData();
fileFd.append('file', file);
try {
const uploadRes = await fetch('/api/prematricula/upload', {
method: 'POST',
body: fileFd
});
if (!uploadRes.ok) throw new Error();
const uploadData = await uploadRes.json();
respostas[input.name] = uploadData.url;
} catch(err) {
showToast('Falha ao enviar arquivo. Tente novamente.', 'error');
btn.disabled=false;btn.textContent='Enviar Pré-Matrícula';
return false;
}
}
}
for(const[k,v]of fd.entries()){
if(!k.startsWith('_') && !(v instanceof File)){
respostas[k]=v;
}
}
try{
const res=await fetch('/api/prematricula/submit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nome,email,telefone,turmaId,turmaNome,respostas})});
if(res.ok){
app.innerHTML='<div class="top-bar"></div><div class="success"><div class="icon">✅</div><h2>Enviado com sucesso!</h2><p>'+window._successMsg+'</p></div>';
}else{
const err=await res.json();showToast(err.error||'Erro ao enviar.', 'error');btn.disabled=false;btn.textContent='Enviar Pré-Matrícula';
}
}catch(err){showToast('Erro de conexão. Verifique sua internet.', 'warning');btn.disabled=false;btn.textContent='Enviar Pré-Matrícula'}
return false;
}
init();
</script>
</body></html>`;
}
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
let activeCronJobBirthday = null; // Referência global para o agendamento de aniversário
// === Funções Auxiliares de Notificação ===
async function createAdminNotification(titulo, mensagem, metadata = {}) {
try {
await pool.query(
'INSERT INTO notificacoes (aluno_id, titulo, mensagem, anexo) VALUES ($1, $2, $3, $4)',
['admin', titulo, mensagem, JSON.stringify(metadata)]
);
console.log(`[Notification] Alerta Admin criado: ${titulo}`);
} catch (err) {
console.error('[Notification] Erro ao criar alerta admin:', err.message);
}
}
// Proxy de Imagens do MinIO (acesso público via backend)
app.get(/^\/storage\/([^\/]+)\/(.+)$/, async (req, res) => {
try {
const bucket = req.params[0];
const key = req.params[1]; // Captura tudo que vem após o bucket (incluindo barras)
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
const data = await s3Client.send(command);
res.set('Content-Type', data.ContentType || 'image/jpeg');
res.set('Cache-Control', 'public, max-age=86400');
data.Body.pipe(res);
} catch (e) {
console.error(`[Storage Proxy] Erro ao buscar: ${req.params.bucket}/${req.params[0]}`, e.message);
res.status(404).send('Arquivo não encontrado');
}
});
const multerStorage = multer.memoryStorage();
const upload = multer({ storage: multerStorage, limits: { fileSize: 10 * 1024 * 1024 } });
// ============================================================
// ROTA NOVA: Login Administrativo (JWT)
// ============================================================
app.post('/api/auth/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Usuário e senha são obrigatórios' });
}
const { rows } = await pool.query(
'SELECT * FROM usuarios WHERE username = $1',
[username]
);
const user = rows[0];
if (!user || user.password !== password) {
return res.status(401).json({ error: 'Credenciais inválidas' });
}
const token = jwt.sign(
{ userId: user.id, username: user.username, role: user.role },
JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({ token, user: { id: user.id, name: user.display_name || user.username, role: user.role } });
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
// ============================================================
// ROTA NOVA: API para o dbService.ts do Frontend
// GET /api/school-data → fetchFromCloud()
// PUT /api/school-data → saveToCloud()
// ============================================================
app.get('/api/school-data', async (req, res) => {
try {
const data = await getSchoolData();
// Injetar dados migrados diretamente do PostgreSQL
try {
data.attendance = await getFrequencias();
} catch (e) {
console.error('[SQL] Falha ao carregar frequencias do banco:', e);
}
// Injetar dados de Configurações migrados diretamente do PostgreSQL
try {
const dbConfigs = await getConfiguracoes();
if (dbConfigs.length > 0) {
data.profiles = dbConfigs.map(r => ({
id: r.id,
name: r.nome,
address: r.endereco,
city: r.cidade,
state: r.estado,
zip: r.cep,
cnpj: r.cnpj,
phone: r.telefone,
email: r.email,
type: r.tipo
}));
data.profile = data.profiles.find(p => p.type === 'matriz') || data.profiles[0];
const firstRow = dbConfigs[0];
data.logo = firstRow.logo || '';
if (firstRow.evolution_api_url) {
data.evolutionConfig = {
apiUrl: firstRow.evolution_api_url || '',
instanceName: firstRow.evolution_instance_name || '',
apiKey: firstRow.evolution_api_key || ''
};
}
if (firstRow.message_templates) {
data.messageTemplates = typeof firstRow.message_templates === 'string'
? JSON.parse(firstRow.message_templates)
: firstRow.message_templates;
}
}
} catch (e) {
console.error('[SQL] Falha ao carregar configuracoes do banco:', e);
}
// Normalizar URLs do MinIO para proxy relativo
// Converte URLs como https://storageedu.xxx/bucket/file para /storage/bucket/file
const MINIO_PUBLIC_URL = process.env.MINIO_PUBLIC_URL || '';
const normalizeUrl = (url) => {
if (!url || typeof url !== 'string') return url;
// Se já é uma URL relativa de proxy, manter
if (url.startsWith('/storage/')) return url;
// Se é a URL pública do MinIO, converter para proxy
if (MINIO_PUBLIC_URL && url.startsWith(MINIO_PUBLIC_URL)) {
return url.replace(MINIO_PUBLIC_URL, '/storage');
}
// Fallback: URL com http://localhost:9000 ou http://minio:9000
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
if (match && (url.includes('minio') || url.includes('storageedu') || url.includes(':9000'))) {
return `/storage/${match[1]}`;
}
return url;
};
// Normalizar fotos de alunos
if (data.students) {
data.students.forEach(s => { if (s.photo) s.photo = normalizeUrl(s.photo); });
}
// Normalizar logo
if (data.logo) data.logo = normalizeUrl(data.logo);
if (data.profile?.logo) data.profile.logo = normalizeUrl(data.profile.logo);
// Normalizar fotos nos registros de presença
if (data.attendance) {
data.attendance.forEach(a => { if (a.photo) a.photo = normalizeUrl(a.photo); });
}
// Normalizar imagens de exames
if (data.exams) {
data.exams.forEach(e => {
if (e.questions) e.questions.forEach(q => { if (q.image) q.image = normalizeUrl(q.image); });
});
}
res.json({ data });
} catch (error) {
console.error('Erro ao buscar school_data:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.put('/api/school-data', async (req, res) => {
try {
const schoolData = req.body;
if (!schoolData) return res.status(400).json({ error: 'Dados não fornecidos' });
// Verificação de timestamp para evitar regressão
const current = await getSchoolData();
const cloudTimestamp = current.lastUpdated ? new Date(current.lastUpdated).getTime() : 0;
const localTimestamp = schoolData.lastUpdated ? new Date(schoolData.lastUpdated).getTime() : 0;
if (cloudTimestamp > localTimestamp) {
return res.status(409).json({ success: false, reason: 'newer_version' });
}
// Inicialização de colunas necessárias para automação
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 ao inicializar colunas de automação:', err));
schoolData.lastUpdated = new Date().toISOString();
await saveSchoolData(schoolData);
// Sincronização em tempo real (JSON -> Relacional)
syncJsonToRelationalTables().catch(err => console.error('[Real-time Sync] Erro:', err.message));
res.json({
success: true,
message: 'Dados salvos com sucesso',
lastUpdated: schoolData.lastUpdated
});
} catch (error) {
console.error('Erro ao salvar school-data:', error);
res.status(500).json({ success: false, error: error.message });
}
});
app.get('/api/system-stats', async (req, res) => {
let postgresStats = { dbSize: 'N/A', tableCount: '0' };
try {
const dbResult = await pool.query(`
SELECT pg_size_pretty(pg_database_size(current_database())) as db_size,
(SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public') as table_count
`);
postgresStats = {
dbSize: dbResult.rows[0].db_size,
tableCount: dbResult.rows[0].table_count
};
} catch(e) {
console.error('System Stats (Postgres) Error:', e);
}
let minioStats = { error: true, message: 'Not initialized' };
try {
minioStats = await getMinioStats();
} catch(e) {
console.error('System Stats (MinIO) Error:', e);
minioStats = { error: true, message: e.message };
}
res.json({
postgres: postgresStats,
minio: minioStats
});
});
// ============================================================
// Database Explorer
// ============================================================
app.get('/api/database/tables', async (req, res) => {
try {
const query = `
SELECT
relname as table_name,
pg_size_pretty(pg_total_relation_size(relid)) as total_size,
pg_total_relation_size(relid) as raw_size,
n_live_tup as row_count
FROM pg_stat_user_tables
ORDER BY raw_size DESC;
`;
const result = await pool.query(query);
res.json({ tables: result.rows });
} catch (error) {
console.error('Erro ao listar tabelas:', error);
res.status(500).json({ error: error.message });
}
});
app.get('/api/database/tables/:tableName/data', async (req, res) => {
try {
const { tableName } = req.params;
// Basic validation to prevent SQL injection on table name
if (!/^[a-zA-Z0-9_]+$/.test(tableName)) {
return res.status(400).json({ error: 'Nome de tabela inválido' });
}
const query = `SELECT * FROM "${tableName}" LIMIT 100;`;
const result = await pool.query(query);
res.json({ rows: result.rows, fields: result.fields.map(f => f.name) });
} catch (error) {
console.error(`Erro ao buscar dados da tabela ${req.params.tableName}:`, error);
res.status(500).json({ error: error.message });
}
});
// ============================================================
// MinIO Explorer
// ============================================================
app.get('/api/storage/buckets/:bucketName/objects', async (req, res) => {
try {
const { bucketName } = req.params;
const objects = await getBucketObjects(bucketName);
res.json({ objects });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.delete('/api/storage/buckets/:bucketName/objects', async (req, res) => {
try {
const { bucketName } = req.params;
const { key } = req.body;
if (!key) return res.status(400).json({ error: 'Key is required' });
await deleteMinioObject(bucketName, key);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ============================================================
// Rota para buscar submissões (acertos/erros) do aluno
// ============================================================
app.get('/api/student-submissions/:studentId', async (req, res) => {
try {
const { studentId } = req.params;
const { rows } = await pool.query(
'SELECT prova_id as "prova_id", acertos, erros FROM provas_submissoes WHERE TRIM(aluno_id) = TRIM($1)',
[String(studentId).trim()]
);
res.json({ submissions: rows });
} catch (err) {
console.error('Erro ao buscar submissões do aluno:', err);
res.status(500).json({ error: 'Erro interno' });
}
});
// ============================================================
// ROTAS DE NOTAS (NOVA TABELA)
// ============================================================
app.get('/api/notas/:alunoId', async (req, res) => {
try {
const { rows: dbNotas } = await pool.query(
'SELECT id, aluno_id as "aluno_id", disciplina_id as "disciplina_id", periodo_id as "periodo_id", prova_id as "prova_id", valor as "valor" FROM notas_boletim WHERE TRIM(aluno_id) = TRIM($1)',
[String(req.params.alunoId).trim()]
);
// Garantir cast numérico para evitar erro de .toFixed no frontend
const notas = dbNotas.map(n => ({ ...n, valor: Number(n.valor) }));
res.json({ notas });
} catch (err) {
console.error('Erro ao buscar notas do aluno:', err);
res.status(500).json({ error: 'Erro interno' });
}
});
app.post('/api/notas', async (req, res) => {
try {
const { notas } = req.body;
if (!Array.isArray(notas)) return res.status(400).json({ error: 'Formato inválido' });
for (const nota of notas) {
if (nota.valor !== null && nota.valor !== '' && !isNaN(Number(nota.valor))) {
await upsertNota({
aluno_id: String(nota.aluno_id),
disciplina_id: String(nota.disciplina_id),
periodo_id: String(nota.periodo_id),
prova_id: nota.prova_id ? String(nota.prova_id) : null,
valor: Number(nota.valor)
});
}
}
// Opcionalmente implementar delete para notas que o professor limpou (vazio)
res.json({ success: true });
} catch (err) {
console.error('Erro ao salvar notas manuais:', err);
res.status(500).json({ error: 'Erro interno' });
}
});
// ============================================================
// ROTAS DE CURSOS (MIGRAÇÃO FASE 3)
// ============================================================
app.get('/api/cursos', async (req, res) => {
try {
const cursos = await getCursos();
res.json({ cursos });
} catch (error) {
console.error('Erro ao buscar cursos:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.post('/api/cursos', async (req, res) => {
try {
await insertCurso(req.body);
res.json({ success: true });
} catch (error) {
console.error('Erro ao criar curso:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.put('/api/cursos/:id', async (req, res) => {
try {
await updateCurso(req.params.id, req.body);
res.json({ success: true });
} catch (error) {
console.error('Erro ao atualizar curso:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.delete('/api/cursos/:id', async (req, res) => {
try {
await deleteCurso(req.params.id);
res.json({ success: true });
} catch (error) {
console.error('Erro ao deletar curso:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
// ============================================================
// ROTAS DE DISCIPLINAS (MIGRAÇÃO FASE 3)
// ============================================================
app.get('/api/disciplinas', async (req, res) => {
try {
const disciplinas = await getDisciplinas();
res.json({ disciplinas });
} catch (error) {
console.error('Erro ao buscar disciplinas:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.post('/api/disciplinas', async (req, res) => {
try {
await insertDisciplina(req.body);
res.json({ success: true });
} catch (error) {
console.error('Erro ao criar disciplina:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.put('/api/disciplinas/:id', async (req, res) => {
try {
await updateDisciplina(req.params.id, req.body);
res.json({ success: true });
} catch (error) {
console.error('Erro ao atualizar disciplina:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.delete('/api/disciplinas/:id', async (req, res) => {
try {
await deleteDisciplina(req.params.id);
res.json({ success: true });
} catch (error) {
console.error('Erro ao deletar disciplina:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
// ============================================================
// ROTAS DE ALUNOS (MIGRAÇÃO FASE 4)
// ============================================================
app.get('/api/fix-alunos-migracao', async (req, res) => {
try {
const result = await pool.query(`
UPDATE alunos a
SET
data_nascimento = NULLIF(s.elem->>'data_nascimento', '')::timestamp,
rg = s.elem->>'rg',
rua = s.elem->>'rua',
numero = s.elem->>'numero',
bairro = s.elem->>'bairro',
cidade = s.elem->>'cidade',
estado = s.elem->>'estado',
cep = s.elem->>'cep'
FROM (
SELECT jsonb_array_elements(data->'students') as elem
FROM school_data
WHERE id = 1
) s
WHERE a.id = s.elem->>'id'
RETURNING a.nome;
`);
res.json({ success: true, message: `Foram atualizados ${result.rowCount} alunos com os dados do JSON original!` });
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
});
app.get('/api/alunos', async (req, res) => {
try {
const alunos = await getAlunos();
res.json({ alunos });
} catch (error) {
console.error('Erro ao buscar alunos:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.post('/api/alunos', async (req, res) => {
try {
const student = req.body;
await insertAluno(student);
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbAlunos = await getAlunos();
appData.students = dbAlunos;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao criar aluno:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.put('/api/alunos/:id', async (req, res) => {
try {
const { id } = req.params;
const student = req.body;
await updateAluno(id, student);
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbAlunos = await getAlunos();
appData.students = dbAlunos;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao atualizar aluno:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.delete('/api/alunos/:id', async (req, res) => {
try {
const { id } = req.params;
// 1. Relational cleanups in Postgres
await pool.query('DELETE FROM alunos_cobrancas WHERE aluno_id = $1', [id]);
await pool.query('DELETE FROM contratos WHERE aluno_id = $1', [id]);
await pool.query('DELETE FROM frequencias WHERE aluno_id = $1', [id]);
await pool.query('DELETE FROM notificacoes WHERE aluno_id = $1', [id]);
await pool.query('DELETE FROM provas_submissoes WHERE aluno_id = $1', [id]);
await pool.query('DELETE FROM notas_boletim WHERE aluno_id = $1', [id]);
await deleteAluno(id);
// 2. Reverse sync to legacy JSON
const appData = await getSchoolData();
appData.students = appData.students.filter(s => s.id !== id);
appData.payments = appData.payments.filter(p => p.studentId !== id);
appData.contracts = appData.contracts.filter(c => c.studentId !== id);
if (appData.attendance) appData.attendance = appData.attendance.filter(a => a.studentId !== id);
if (appData.notifications) appData.notifications = appData.notifications.filter(n => n.studentId !== id);
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao deletar aluno:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
// ============================================================
// ROTAS DE CONTRATOS E MODELOS
// ============================================================
app.get('/api/modelos-contrato', async (req, res) => {
try { res.json({ modelos: await getModelosContrato() }); } catch (e) { res.status(500).json({ error: 'Erro' }); }
});
app.post('/api/modelos-contrato', async (req, res) => {
try {
await insertModeloContrato(req.body);
const appData = await getSchoolData();
appData.contractTemplates = await getModelosContrato();
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Erro' }); }
});
app.put('/api/modelos-contrato/:id', async (req, res) => {
try {
await updateModeloContrato(req.params.id, req.body);
const appData = await getSchoolData();
appData.contractTemplates = await getModelosContrato();
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Erro' }); }
});
app.delete('/api/modelos-contrato/:id', async (req, res) => {
try {
await deleteModeloContrato(req.params.id);
const appData = await getSchoolData();
appData.contractTemplates = await getModelosContrato();
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Erro' }); }
});
app.get('/api/contratos', async (req, res) => {
try { res.json({ contratos: await getContratos() }); } catch (e) { res.status(500).json({ error: 'Erro' }); }
});
app.post('/api/contratos', async (req, res) => {
try {
await insertContrato(req.body);
const appData = await getSchoolData();
appData.contracts = await getContratos();
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Erro' }); }
});
app.put('/api/contratos/:id', async (req, res) => {
try {
await updateContrato(req.params.id, req.body);
const appData = await getSchoolData();
appData.contracts = await getContratos();
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Erro' }); }
});
app.delete('/api/contratos/:id', async (req, res) => {
try {
await deleteContrato(req.params.id);
const appData = await getSchoolData();
appData.contracts = await getContratos();
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Erro' }); }
});
// ============================================================
// ROTAS DE AULAS E CRONOGRAMA
// ============================================================
app.get('/api/aulas', async (req, res) => {
try {
const { turma_id } = req.query;
const aulas = turma_id ? await getAulasByTurma(turma_id) : await getAllAulas();
res.json({ aulas });
} catch (error) {
console.error('Erro ao buscar aulas:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.post('/api/aulas/lote', async (req, res) => {
try {
const { aulas } = req.body;
await insertAulas(aulas);
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbAulas = await getAllAulas();
appData.lessons = dbAulas;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao inserir aulas em lote:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.delete('/api/aulas/lote', async (req, res) => {
try {
const { ids } = req.body;
await deleteAulas(ids);
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbAulas = await getAllAulas();
appData.lessons = dbAulas;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao deletar aulas em lote:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
// ============================================================
// ROTAS DE FREQUÊNCIAS (CHAMADA)
// ============================================================
app.get('/api/frequencias', async (req, res) => {
try {
const frequencias = await getFrequencias();
res.json({ frequencias });
} catch (error) {
console.error('Erro ao buscar frequencias:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.post('/api/frequencias', async (req, res) => {
try {
await insertFrequencia(req.body);
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbFrequencias = await getFrequencias();
appData.attendance = dbFrequencias;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao inserir frequencia:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.put('/api/frequencias/:id', async (req, res) => {
try {
await updateFrequencia(req.params.id, req.body);
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbFrequencias = await getFrequencias();
appData.attendance = dbFrequencias;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao atualizar frequencia:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.delete('/api/frequencias/:id', async (req, res) => {
try {
await deleteFrequencia(req.params.id);
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbFrequencias = await getFrequencias();
appData.attendance = dbFrequencias;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao deletar frequencia:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
// ============================================================
// ROTAS DE AVALIAÇÕES (MIGRAÇÃO FASE 5)
// ============================================================
app.get('/api/provas', async (req, res) => {
try {
const provas = await getProvas();
res.json({ provas });
} catch (error) {
console.error('Erro ao buscar provas:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.get('/api/provas/:id/questoes', async (req, res) => {
try {
const questoes = await getQuestoesDaProva(req.params.id);
res.json({ questoes });
} catch (error) {
console.error('Erro ao buscar questoes:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.post('/api/provas', async (req, res) => {
try {
const prova = req.body;
await insertProva(prova);
// Sync questions if provided
if (prova.questions && prova.questions.length > 0) {
await syncQuestoesProva(prova.id, prova.questions);
}
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbProvas = await getProvas();
appData.exams = dbProvas;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao criar prova:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.put('/api/provas/:id', async (req, res) => {
try {
const { id } = req.params;
const prova = req.body;
await updateProva(id, prova);
// Sync questions if provided
if (prova.questions) {
await syncQuestoesProva(id, prova.questions);
}
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbProvas = await getProvas();
appData.exams = dbProvas;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao atualizar prova:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.delete('/api/provas/:id', async (req, res) => {
try {
const { id } = req.params;
// Cleanup relational dependencies
await pool.query('DELETE FROM questoes_provas WHERE prova_id = $1', [id]);
await pool.query('DELETE FROM provas_submissoes WHERE prova_id = $1', [id]);
await deleteProva(id);
// Reverse sync to legacy JSON
const appData = await getSchoolData();
appData.exams = (appData.exams || []).filter(e => e.id !== id);
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao deletar prova:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.post('/api/provas/:id/questoes', async (req, res) => {
try {
const { questoes } = req.body;
await syncQuestoesProva(req.params.id, questoes || []);
res.json({ success: true });
} catch (error) {
console.error('Erro ao sincronizar questoes:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.post('/api/ai/generate-questions', async (req, res) => {
const { topic, quantity, difficulty, contextText } = req.body;
if (!topic) {
return res.status(400).json({ error: 'Tema não informado' });
}
const limitQty = Math.min(Math.max(Number(quantity) || 1, 1), 20);
try {
const appData = await getSchoolData();
const aiConfig = appData.aiConfig || {};
const provider = aiConfig.activeProvider || 'gemini';
let apiKey = '';
let modelName = '';
if (provider === 'gemini') {
apiKey = aiConfig.geminiKey;
modelName = aiConfig.geminiModel || 'gemini-1.5-flash';
} else if (provider === 'openai') {
apiKey = aiConfig.openaiKey;
modelName = aiConfig.openaiModel || 'gpt-4o-mini';
} else if (provider === 'claude') {
apiKey = aiConfig.claudeKey;
modelName = aiConfig.claudeModel || 'claude-3-5-sonnet-20241022';
}
if (!apiKey) {
return res.status(400).json({ error: `Chave de API não configurada para o provedor ${provider.toUpperCase()} no painel da escola.` });
}
let userContent = `Gere exatamente ${limitQty} questões sobre o tema "${topic}" com nível de dificuldade "${difficulty}" em Português do Brasil.`;
if (contextText && contextText.trim()) {
userContent += `\n\nATENÇÃO: Você DEVE basear as questões EXCLUSIVAMENTE nas informações fornecidas no Material de Apoio abaixo:\n\n=== INÍCIO DO MATERIAL DE APOIO ===\n${contextText}\n=== FIM DO MATERIAL DE APOIO ===`;
}
if (provider === 'openai') {
const payload = {
model: modelName,
messages: [
{
role: 'system',
content: `Você é um gerador de questões escolares de múltipla escolha para provas de alunos.
Retorne um objeto JSON contendo uma lista de questões na chave "questions".
Cada questão deve conter:
- "text": o enunciado da questão.
- "options": um array com exatamente 4 opções de resposta.
- "correctOptionIndex": o índice da opção correta (de 0 a 3).
Exemplo de formato esperado:
{
"questions": [
{
"text": "Qual é a principal função do processador?",
"options": ["Processar dados", "Armazenar arquivos", "Exibir imagens", "Conectar à internet"],
"correctOptionIndex": 0
}
]
}`
},
{
role: 'user',
content: userContent
}
],
response_format: { type: 'json_object' }
};
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenAI API retornou erro: ${response.status} - ${errorText}`);
}
const resJson = await response.json();
const contentText = resJson.choices?.[0]?.message?.content;
if (!contentText) {
throw new Error('Retorno vazio da API do OpenAI');
}
const parsed = JSON.parse(contentText);
const questions = parsed.questions || parsed || [];
return res.json({ questions });
} else if (provider === 'claude') {
const payload = {
model: modelName,
max_tokens: 4096,
system: `Você é um gerador de questões escolares de múltipla escolha para provas de alunos.
Retorne APENAS um array de objetos JSON estruturado (sem blocos de código markdown como \`\`\`json ou texto explicativo extra).
Cada questão do array deve conter obrigatoriamente:
- "text": o enunciado da questão.
- "options": um array com exatamente 4 opções de resposta.
- "correctOptionIndex": o índice da opção correta (de 0 a 3).
Exemplo de formato esperado:
[
{
"text": "Qual é a principal função do processador?",
"options": ["Processar dados", "Armazenar arquivos", "Exibir imagens", "Conectar à internet"],
"correctOptionIndex": 0
}
]`,
messages: [
{
role: 'user',
content: userContent
}
]
};
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Claude API retornou erro: ${response.status} - ${errorText}`);
}
const resJson = await response.json();
let contentText = resJson.content?.[0]?.text || '';
contentText = contentText.trim();
if (contentText.startsWith('```json')) {
contentText = contentText.substring(7);
} else if (contentText.startsWith('```')) {
contentText = contentText.substring(3);
}
if (contentText.endsWith('```')) {
contentText = contentText.substring(0, contentText.length - 3);
}
contentText = contentText.trim();
const questions = JSON.parse(contentText);
return res.json({ questions });
} else {
// Provedor padrão: Google Gemini
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${apiKey}`;
const payload = {
contents: [
{
parts: [
{
text: `${userContent}
Apenas retorne o JSON estruturado contendo a lista de questões no seguinte formato de array:
[
{
"text": "Enunciado da questão",
"options": ["opção 1", "opção 2", "opção 3", "opção 4"],
"correctOptionIndex": 0
}
]`
}
]
}
],
generationConfig: {
responseMimeType: 'application/json',
responseSchema: {
type: 'ARRAY',
items: {
type: 'OBJECT',
properties: {
text: { type: 'STRING' },
options: {
type: 'ARRAY',
items: { type: 'STRING' },
minItems: 4,
maxItems: 4
},
correctOptionIndex: { type: 'INTEGER' }
},
required: ['text', 'options', 'correctOptionIndex']
}
}
}
};
const response = await fetch(geminiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Gemini API retornou erro: ${response.status} - ${errorText}`);
}
const resJson = await response.json();
const textResponse = resJson.candidates?.[0]?.content?.parts?.[0]?.text;
if (!textResponse) {
throw new Error('Retorno vazio da API do Gemini');
}
const questions = JSON.parse(textResponse);
return res.json({ questions });
}
} catch (error) {
console.error('Erro na geração de IA:', error);
res.status(500).json({ error: error.message || 'Erro ao processar requisição com a IA' });
}
});
app.post('/api/ai/generate-image', async (req, res) => {
const { prompt } = req.body;
if (!prompt) {
return res.status(400).json({ error: 'Prompt não informado' });
}
try {
const appData = await getSchoolData();
const aiConfig = appData.aiConfig || {};
let provider = '';
let apiKey = '';
if (aiConfig.openaiKey) {
provider = 'openai';
apiKey = aiConfig.openaiKey;
} else if (aiConfig.geminiKey) {
provider = 'gemini';
apiKey = aiConfig.geminiKey;
}
if (!apiKey) {
return res.status(400).json({ error: 'Nenhuma chave de API de IA (OpenAI ou Gemini) cadastrada para geração de imagens.' });
}
let imageUrl = '';
if (provider === 'openai') {
const modelName = aiConfig.openaiModel || 'gpt-4o-mini';
const imageModel = modelName.includes('gpt-4') ? 'dall-e-3' : 'dall-e-2';
const response = await fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
prompt: `Ilustração educativa e profissional estilo flat vector sobre: ${prompt}`,
n: 1,
size: imageModel === 'dall-e-3' ? '1024x1024' : '512x512',
model: imageModel
})
});
if (!response.ok) {
const errorText = await response.text();
console.warn('Tentativa de geração de imagem falhou, tentando dall-e-2 de fallback. Erro:', errorText);
const responseD2 = await fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
prompt: `Ilustração educativa simples sobre: ${prompt}`,
n: 1,
size: '512x512',
model: 'dall-e-2'
})
});
if (!responseD2.ok) {
const errText2 = await responseD2.text();
throw new Error(`OpenAI DALL-E retornou erro: ${errText2}`);
}
const resJson2 = await responseD2.json();
imageUrl = resJson2.data?.[0]?.url;
} else {
const resJson = await response.json();
imageUrl = resJson.data?.[0]?.url;
}
} else {
// Usar Google Gemini Imagen 3
const imagenUrl = `https://generativelanguage.googleapis.com/v1beta/models/imagen-3.0-generate-002:predict?key=${apiKey}`;
const payload = {
instances: [
{
prompt: `Ilustração educativa flat vector sobre: ${prompt}`
}
],
parameters: {
sampleCount: 1,
aspectRatio: "1:1",
outputMimeType: "image/jpeg"
}
};
const response = await fetch(imagenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Gemini Imagen retornou erro: ${errText}`);
}
const resJson = await response.json();
const base64ImageBytes = resJson.predictions?.[0]?.bytesBase64Encoded;
if (!base64ImageBytes) {
throw new Error('Retorno vazio da API Imagen do Gemini');
}
const imageBuffer = Buffer.from(base64ImageBytes, 'base64');
const { uploadExamImage } = await import('./services/storage.js');
const savedUrl = await uploadExamImage(imageBuffer, 'image/jpeg');
return res.json({ url: savedUrl });
}
if (!imageUrl) {
throw new Error('Não foi possível obter a URL da imagem gerada');
}
// Baixar imagem gerada e enviar ao MinIO
const imgResponse = await fetch(imageUrl);
if (!imgResponse.ok) {
throw new Error(`Erro ao baixar imagem da IA: ${imgResponse.statusText}`);
}
const contentType = imgResponse.headers.get('content-type') || 'image/png';
const blobBuffer = Buffer.from(await imgResponse.arrayBuffer());
const { uploadExamImage } = await import('./services/storage.js');
const savedUrl = await uploadExamImage(blobBuffer, contentType);
res.json({ url: savedUrl });
} catch (error) {
console.error('Erro ao gerar imagem com IA:', error);
res.status(500).json({ error: error.message || 'Erro ao gerar ou salvar imagem da IA' });
}
});
// Helper para formatar datas para o JSON
const formatDateForJSON = (val) => {
if (!val) return '';
if (val instanceof Date) {
return val.toISOString().substring(0, 10);
}
if (typeof val === 'string') {
return val.substring(0, 10);
}
return '';
};
// Helper para sincronizar turmas do SQL para o JSON legado (evitando desaparecimento)
const reverseSyncTurmas = async () => {
const dbTurmas = await getTurmas();
const appData = await getSchoolData();
appData.classes = dbTurmas.map((t) => ({
id: t.id,
name: t.nome,
courseId: t.curso_id,
teacher: t.professor,
schedule: t.horario,
scheduleDay: t.dia_semana,
maxStudents: Number(t.max_alunos || 0),
startDate: formatDateForJSON(t.data_inicio),
endDate: formatDateForJSON(t.data_fim),
defaultStartTime: t.horario_inicio_padrao,
defaultEndTime: t.horario_fim_padrao
}));
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
};
// ============================================================
// ROTAS DE TURMAS (MIGRAÇÃO FASE 3)
// ============================================================
app.get('/api/turmas', async (req, res) => {
try {
const turmas = await getTurmas();
res.json({ turmas });
} catch (error) {
console.error('Erro ao buscar turmas:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.post('/api/turmas', async (req, res) => {
try {
await insertTurma(req.body);
await reverseSyncTurmas();
res.json({ success: true });
} catch (error) {
console.error('Erro ao criar turma:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.put('/api/turmas/:id', async (req, res) => {
try {
await updateTurma(req.params.id, req.body);
await reverseSyncTurmas();
res.json({ success: true });
} catch (error) {
console.error('Erro ao atualizar turma:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.delete('/api/turmas/:id', async (req, res) => {
try {
await deleteTurma(req.params.id);
await reverseSyncTurmas();
res.json({ success: true });
} catch (error) {
console.error('Erro ao deletar turma:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
// ============================================================
// ROTAS DE FUNCIONÁRIOS (MIGRAÇÃO FASE 1)
// ============================================================
app.get('/api/categorias_funcionarios', async (req, res) => {
try {
const categorias = await getCategoriasFuncionarios();
res.json({ categorias });
} catch (error) {
console.error('Erro ao buscar categorias:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.post('/api/categorias_funcionarios', async (req, res) => {
try {
const data = req.body;
await insertCategoriaFuncionario(data);
res.json({ success: true, categoria: data });
} catch (error) {
console.error('Erro ao criar categoria:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.put('/api/categorias_funcionarios/:id', async (req, res) => {
try {
const { id } = req.params;
const { nome } = req.body;
await updateCategoriaFuncionario(id, nome);
res.json({ success: true });
} catch (error) {
console.error('Erro ao atualizar categoria:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.delete('/api/categorias_funcionarios/:id', async (req, res) => {
try {
const { id } = req.params;
await deleteCategoriaFuncionario(id);
res.json({ success: true });
} catch (error) {
console.error('Erro ao deletar categoria:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.get('/api/funcionarios', async (req, res) => {
try {
const funcionarios = await getFuncionarios();
res.json({ funcionarios });
} catch (error) {
console.error('Erro ao buscar funcionarios:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.post('/api/funcionarios', async (req, res) => {
try {
const data = req.body;
await insertFuncionario(data);
res.json({ success: true, funcionario: data });
} catch (error) {
console.error('Erro ao criar funcionario:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.put('/api/funcionarios/:id', async (req, res) => {
try {
const { id } = req.params;
const updateData = req.body;
await updateFuncionario(id, updateData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao atualizar funcionario:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.delete('/api/funcionarios/:id', async (req, res) => {
try {
const { id } = req.params;
await deleteFuncionario(id);
res.json({ success: true });
} catch (error) {
console.error('Erro ao deletar funcionario:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
// ============================================================
// Upload de Logo (MinIO em vez de Supabase Storage)
// ============================================================
app.post('/api/upload/logo', upload.single('logo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
}
const compressedBuffer = await sharp(req.file.buffer)
.resize(500, 500, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 60 })
.toBuffer();
const url = await uploadLogoToStorage(compressedBuffer, 'image/webp');
return res.status(200).json({ url });
} catch (error) {
console.error('Erro ao processar logo:', error);
return res.status(500).json({ error: 'Erro interno ao processar a imagem.' });
}
});
// ============================================================
// Upload de Foto de Aluno (MinIO)
// ============================================================
app.post('/api/upload/student-photo', upload.single('photo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
}
const { uploadStudentPhoto } = await import('./services/storage.js');
const url = await uploadStudentPhoto(req.file.buffer, req.file.mimetype);
return res.status(200).json({ url });
} catch (error) {
console.error('Erro ao processar foto:', error);
return res.status(500).json({ error: 'Erro interno.' });
}
});
// ============================================================
// Upload de Logo da Escola (MinIO)
// ============================================================
app.post('/api/upload/logo', upload.single('logo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
}
const { uploadLogo } = await import('./services/storage.js');
const url = await uploadLogo(req.file.buffer, req.file.mimetype);
return res.status(200).json({ url });
} catch (error) {
console.error('Erro ao processar logo:', error);
return res.status(500).json({ error: 'Erro interno.' });
}
});
// ============================================================
// Upload de Imagem de Avaliação (MinIO)
// ============================================================
app.post('/api/upload/exam-image', upload.single('photo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
}
const { uploadExamImage } = await import('./services/storage.js');
const url = await uploadExamImage(req.file.buffer, req.file.mimetype);
return res.status(200).json({ url });
} catch (error) {
console.error('Erro ao processar imagem de avaliação:', error);
return res.status(500).json({ error: 'Erro interno.' });
}
});
// ============================================================
// Formatação de Data
// ============================================================
function formatCobrancaDate(dateStr) {
if (!dateStr) return '';
const [Ano, Mes, Dia] = dateStr.split('-');
if (!Dia) return dateStr;
return `${Dia}/${Mes}/${Ano}`;
}
// ============================================================
// Integração WhatsApp Evolution API
// (Mesma lógica, trocando supabase por database.js)
// ============================================================
async function sendEvolutionMessage(asaasPaymentId, eventType, fallbackValorArg = null, fallbackVencimentoArg = null) {
try {
let cob = null;
for (let i = 0; i < 3; i++) {
cob = await getCobrancaByPaymentId(asaasPaymentId);
if (cob) break;
if (i < 2) await new Promise(r => setTimeout(r, 1000));
}
if (!cob) return console.log(`[Evolution] Cobrança não encontrada: ${asaasPaymentId}`);
let fallbackValor = fallbackValorArg || cob.valor;
let fallbackVencimento = fallbackVencimentoArg || cob.vencimento;
let fallbackDescricao = 'serviços educacionais';
const configResult = await pool.query('SELECT * FROM configuracoes LIMIT 1');
const firstRow = configResult.rows[0];
if (!firstRow) return console.log('[WhatsApp] Configurações não encontradas no PostgreSQL');
const evoConfig = {
apiUrl: firstRow.evolution_api_url,
instanceName: firstRow.evolution_instance_name,
apiKey: firstRow.evolution_api_key
};
const templates = typeof firstRow.message_templates === 'string'
? JSON.parse(firstRow.message_templates)
: firstRow.message_templates || {};
const profile = {
name: firstRow.nome,
cnpj: firstRow.cnpj,
phone: firstRow.telefone,
email: firstRow.email,
address: firstRow.endereco,
city: firstRow.cidade,
state: firstRow.estado,
zip: firstRow.cep,
type: firstRow.tipo
};
if (!evoConfig.apiUrl || !evoConfig.apiKey || !evoConfig.instanceName) {
return console.log('[WhatsApp] Credenciais Evolution não configuradas.');
}
const normalizedEvent = (eventType === 'PAYMENT_RECEIVED' || eventType === 'PAYMENT_CONFIRMED') ? 'PAYMENT_RECEIVED' : eventType;
const cacheKey = `${asaasPaymentId}_${normalizedEvent}`;
if (sentCache.has(cacheKey)) return;
sentCache.add(cacheKey);
setTimeout(() => sentCache.delete(cacheKey), 30000);
const { rows: studentRows } = await pool.query('SELECT id, nome, telefone, telefone_responsavel, data_nascimento, numero_matricula FROM alunos WHERE id = $1', [cob.aluno_id]);
let aluno = null;
if (studentRows[0]) {
const a = studentRows[0];
aluno = {
id: a.id,
name: a.nome,
phone: a.telefone,
guardianPhone: a.telefone_responsavel,
birthDate: a.data_nascimento,
enrollmentNumber: a.numero_matricula
};
}
if (!aluno) return console.log('[WhatsApp] Aluno não encontrado:', cob.aluno_id);
let age = 18; // Padrão adulto para evitar travas se bday faltar
if (aluno.birthDate) {
const bDate = new Date(aluno.birthDate);
const today = new Date();
age = today.getFullYear() - bDate.getFullYear();
const m = today.getMonth() - bDate.getMonth();
if (m < 0 || (m === 0 && today.getDate() < bDate.getDate())) age--;
}
const isMinor = age < 18;
// Seleção resiliente: Tenta responsável se menor, mas aceita o do aluno se o do pai faltar (e vice-versa)
const targetPhone = isMinor
? (aluno.guardianPhone || aluno.phone)
: (aluno.phone || aluno.guardianPhone);
const targetName = (isMinor && aluno.guardianName) ? aluno.guardianName : aluno.name;
if (!targetPhone) return console.log('[WhatsApp] Sem telefone.');
let cleanPhone = targetPhone.replace(/\D/g, '');
if (cleanPhone.length === 10 || cleanPhone.length === 11) cleanPhone = '55' + cleanPhone;
let descricao = fallbackDescricao;
let pdfUrl = cob.link_carne || cob.link_boleto || '';
let isCarneCompleto = false;
const pResp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${asaasPaymentId}`, {
headers: { 'access_token': process.env.ASAAS_API_KEY }
});
if (pResp.ok) {
const pData = await pResp.json();
if (pData.description) descricao = pData.description;
if (pData.value) fallbackValor = pData.value;
if (pData.dueDate) fallbackVencimento = pData.dueDate;
if (descricao.includes('Parcela')) {
if (eventType === 'PAYMENT_CREATED') descricao = descricao.replace(' de ', ' a ');
else if (['PAYMENT_RECEIVED', 'PAYMENT_CONFIRMED', 'PAYMENT_UPDATED'].includes(eventType)) {
descricao = descricao.replace(/Parcela (\d+) a (\d+)/g, 'Parcela $1 de $2');
}
}
if (pData.installment && eventType === 'PAYMENT_CREATED') {
if (pData.installmentNumber > 1) return;
isCarneCompleto = true;
pdfUrl = `${ASAAS_BASE_URL}/v3/installments/${pData.installment}/paymentBook`;
} else {
pdfUrl = pData.transactionReceiptUrl || pData.bankSlipUrl || pData.invoiceUrl || pdfUrl;
}
}
const fbAVencer = 'Olá {nome}, lembramos que sua cobrança referente a {descricao} no valor de R$ {valor} vencerá em {vencimento}. Segue o PDF abaixo:';
let templateText = '';
if (eventType === 'PAYMENT_CREATED') templateText = templates?.boletoGerado || fbGerado;
else if (eventType === 'PAYMENT_RECEIVED' || eventType === 'PAYMENT_CONFIRMED') templateText = templates?.pagamentoConfirmado || fbPago;
else if (eventType === 'PAYMENT_OVERDUE') templateText = templates?.boletoVencido || fbAtrasado;
else if (eventType === 'PAYMENT_DELETED') templateText = templates?.cobrancaCancelada || fbCancelado;
else if (eventType === 'PAYMENT_UPDATED') templateText = templates?.cobrancaAtualizada || fbAtualizado;
else if (eventType === 'PAYMENT_UPCOMING') templateText = templates?.boletoAVencer || fbAVencer;
if (!templateText) return;
const valNum = parseFloat(fallbackValor);
const valorFormatado = !isNaN(valNum) ? valNum.toFixed(2).replace('.', ',') : '—';
let msgFinal = templateText
.replace(/{nome}/g, targetName)
.replace(/{nome_aluno}/g, aluno.name)
.replace(/{matricula}/g, aluno.enrollmentNumber || '—')
.replace(/{valor}/g, valorFormatado)
.replace(/{vencimento}/g, formatCobrancaDate(typeof fallbackVencimento === 'string' ? fallbackVencimento : (fallbackVencimento instanceof Date ? fallbackVencimento.toISOString().split('T')[0] : '')))
.replace(/{link_boleto}/g, pdfUrl)
.replace(/{descricao}/g, descricao);
const isPaymentConfirmation = ['PAYMENT_RECEIVED', 'PAYMENT_CONFIRMED'].includes(eventType);
const isCreationEvent = eventType === 'PAYMENT_CREATED';
// 1. GERAÇÃO DE PDF (Recibo ou Boleto)
let base64Pdf = null;
let fileName = `Documento-${targetName.replace(/\s+/g, '')}.pdf`;
if (isPaymentConfirmation) {
// GERAÇÃO DE RECIBO PROFISSIONAL (BACKEND - NODE COMPATIBLE)
try {
let logoDataUrl = null;
let logoFormat = 'JPEG';
try {
const appData = await getSchoolData();
const logoUrl = firstRow.logo || appData.logo;
if (logoUrl) {
if (logoUrl.startsWith('data:image')) {
logoDataUrl = logoUrl;
logoFormat = logoUrl.toLowerCase().includes('png') ? 'PNG' : (logoUrl.toLowerCase().includes('webp') ? 'WEBP' : 'JPEG');
} else if (logoUrl.startsWith('http')) {
const res = await fetch(logoUrl);
if (res.ok) {
const buffer = Buffer.from(await res.arrayBuffer());
logoFormat = 'PNG';
const pngBuffer = await sharp(buffer).png().toBuffer();
logoDataUrl = `data:image/png;base64,${pngBuffer.toString('base64')}`;
}
} else if (logoUrl.startsWith('/storage/') || (!logoUrl.startsWith('http') && !logoUrl.startsWith('data:image'))) {
const cleanUrl = logoUrl.startsWith('/storage/') ? logoUrl : `/storage/${logoUrl}`;
const parts = cleanUrl.split('/').filter(Boolean);
if (parts.length >= 3) {
const bucket = parts[1];
const key = parts.slice(2).join('/');
const buffer = await getFileBuffer(bucket, key);
const pngBuffer = await sharp(buffer).png().toBuffer();
logoFormat = 'PNG';
logoDataUrl = `data:image/png;base64,${pngBuffer.toString('base64')}`;
}
}
}
} catch (e) {
console.error('[WhatsApp] Erro ao obter logo:', e.message);
}
const doc = new jsPDF();
let currentY = 10;
if (logoDataUrl) {
try {
doc.addImage(logoDataUrl, logoFormat, 20, currentY + 5, 20, 20, undefined, 'FAST');
} catch(e) {
console.error('[WhatsApp] Erro ao desenhar logo no PDF:', e.message);
}
}
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text(profile.name || 'EduManager School', 105, currentY + 15, { align: 'center' });
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text(`CNPJ: ${profile.cnpj || '---'} | Contato: ${profile.phone || ''}`, 105, currentY + 22, { align: 'center' });
currentY += 40;
doc.setFontSize(16);
doc.setFont('helvetica', 'bold');
doc.text('RECIBO DE PAGAMENTO', 105, currentY, { align: 'center' });
currentY += 15;
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
doc.text(`Recebemos de: ${aluno.name}`, 20, currentY);
currentY += 10;
doc.text(`A quantia de: R$ ${valorFormatado}`, 20, currentY);
currentY += 10;
const refText = `Referente a: ${descricao}`;
const splitRef = doc.splitTextToSize(refText, 170);
doc.text(splitRef, 20, currentY);
currentY += (splitRef.length * 6) + 4;
const dataHj = new Date().toLocaleDateString('pt-BR');
const dataPagamento = fallbackVencimento ? formatCobrancaDate(typeof fallbackVencimento === 'string' ? fallbackVencimento : dataHj) : dataHj;
doc.text(`Data do Pagamento: ${dataPagamento}`, 20, currentY);
currentY += 15;
doc.line(60, currentY, 150, currentY);
currentY += 5;
doc.setFontSize(8);
doc.text('Autenticação Digital EduManager', 105, currentY, { align: 'center' });
currentY += 5;
// Moldura Dinâmica
doc.setDrawColor(0);
doc.setLineWidth(0.5);
doc.rect(10, 10, 190, currentY - 10);
const pdfArrayBuffer = doc.output('arraybuffer');
const pdfBuffer = Buffer.from(pdfArrayBuffer);
// Upload para o MinIO (Pasta recibos) — apenas para envio via WhatsApp
const minioFileName = `recibos/recibo_${asaasPaymentId}.pdf`;
const minioUrl = await uploadReceiptToStorage(minioFileName, pdfBuffer);
// NÃO sobrescrever transaction_receipt_url — o link do Asaas (público) é salvo pelo webhook
// O MinIO é usado apenas para o envio do PDF via WhatsApp
base64Pdf = pdfBuffer.toString('base64');
fileName = `Recibo-${targetName.replace(/\s+/g, '')}.pdf`;
} catch (pdfErr) {
console.error('[WhatsApp] Erro ao gerar PDF de recibo:', pdfErr.message);
}
} else if (pdfUrl && !['PAYMENT_DELETED'].includes(eventType)) {
// TENTA BUSCAR PDF EXTERNO (BOLETO/CARNÊ)
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const fetchOptions = { headers: { 'Accept': 'application/pdf' } };
if (pdfUrl.includes('asaas.com')) fetchOptions.headers['access_token'] = process.env.ASAAS_API_KEY;
const pdfResp = await fetch(pdfUrl, fetchOptions);
if (pdfResp.ok && pdfResp.headers.get('content-type')?.includes('pdf')) {
const arrayBuffer = await pdfResp.arrayBuffer();
base64Pdf = Buffer.from(arrayBuffer).toString('base64');
break;
}
if (attempt < 3) await new Promise(r => setTimeout(r, 3000));
} catch (err) {
if (attempt < 3) await new Promise(r => setTimeout(r, 3000));
}
}
}
if ((isCreationEvent || isPaymentConfirmation || eventType === 'PAYMENT_UPDATED' || eventType === 'PAYMENT_UPCOMING') && !base64Pdf && pdfUrl) {
if (!templateText.includes('{link_boleto}')) {
msgFinal += `\n\n📄 Acesse aqui seu documento:\n${pdfUrl}`;
}
}
let endpoint = 'sendText';
let payload = {};
if (base64Pdf) {
endpoint = 'sendMedia';
if (isCarneCompleto) fileName = `Carne-${targetName.replace(/\s+/g, '')}.pdf`;
payload = {
number: cleanPhone,
options: { delay: 1200, presence: "composing" },
mediatype: "document",
mimetype: "application/pdf",
fileName,
media: base64Pdf,
caption: msgFinal
};
} else {
payload = { number: cleanPhone, text: msgFinal };
}
const url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/${endpoint}/${evoConfig.instanceName}`;
const sendResp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': evoConfig.apiKey }, body: JSON.stringify(payload) });
if (sendResp.ok) {
console.log(`[WhatsApp] ✅ Enviado para ${cleanPhone} (${eventType})`);
return true;
} else {
console.error(`[WhatsApp] ❌ Erro ao enviar WhatsApp:`, sendResp.status);
return false;
}
} catch (error) {
console.error('[WhatsApp] Erro interno:', error.message);
return false;
}
}
// ============================================================
// Webhook Asaas (Substituídas chamadas supabase por database.js)
// ============================================================
app.post('/api/webhook_asaas', async (req, res) => {
const tokenRecebido = req.headers['asaas-access-token'];
if (tokenRecebido !== process.env.ASAAS_WEBHOOK_TOKEN) {
addLog('Webhook', 'Auth Negada', 'Token inválido');
return res.status(401).json({ error: 'Não autorizado' });
}
try {
const payload = req.body;
if (payload.dateCreated) {
const diffHours = (Date.now() - new Date(payload.dateCreated).getTime()) / (1000 * 60 * 60);
if (diffHours > 24) return res.status(200).send('OK');
}
const asaasPaymentId = payload.payment.id;
const asaasCustomerId = payload.payment.customer;
let updateData = {};
let targetName = payload.payment.customerName || 'Cliente';
let className = '';
// Buscar o aluno e a turma no banco usando o asaas_payment_id ou asaas_customer_id
try {
let studentRes = await pool.query(
`SELECT a.nome as student_name, t.nome as class_name
FROM alunos a
LEFT JOIN turmas t ON a.turma_id = t.id
WHERE a.id = (
SELECT aluno_id
FROM alunos_cobrancas
WHERE asaas_payment_id = $1
LIMIT 1
)`,
[asaasPaymentId]
);
if (studentRes.rows.length === 0 && asaasCustomerId) {
studentRes = await pool.query(
`SELECT a.nome as student_name, t.nome as class_name
FROM alunos a
LEFT JOIN turmas t ON a.turma_id = t.id
WHERE a.id = (
SELECT aluno_id
FROM alunos_cobrancas
WHERE asaas_customer_id = $1
LIMIT 1
)`,
[asaasCustomerId]
);
}
if (studentRes.rows.length > 0) {
targetName = studentRes.rows[0].student_name || targetName;
className = studentRes.rows[0].class_name || '';
} else {
const nameClean = targetName.trim();
const alunoByName = await pool.query(
`SELECT a.nome as student_name, t.nome as class_name
FROM alunos a
LEFT JOIN turmas t ON a.turma_id = t.id
WHERE LOWER(a.nome) = LOWER($1)
LIMIT 1`,
[nameClean]
);
if (alunoByName.rows.length > 0) {
targetName = alunoByName.rows[0].student_name || targetName;
className = alunoByName.rows[0].class_name || '';
}
}
} catch (err) {
console.error('[Webhook:StudentLookup] Erro ao resolver aluno/turma:', err.message);
}
const studentDisplay = className ? `${targetName} (${className})` : targetName;
switch (payload.event) {
case 'PAYMENT_CREATED':
setTimeout(() => sendEvolutionMessage(asaasPaymentId, 'PAYMENT_CREATED'), 2000);
return res.status(200).json({ message: 'OK' });
case 'PAYMENT_RECEIVED':
case 'PAYMENT_CONFIRMED':
updateData = {
status: 'PAGO',
valor_pago: payload.payment.value,
data_pagamento: payload.payment.confirmedDate || payload.payment.paymentDate || new Date().toISOString().split('T')[0]
};
// Preservar o valor original da parcela — não inflar com desconto
try {
const cobRes = await pool.query('SELECT valor, discount, amount_original FROM alunos_cobrancas WHERE asaas_payment_id = $1', [asaasPaymentId]);
if (cobRes.rows.length > 0) {
const cob = cobRes.rows[0];
const storedValor = Number(cob.valor || 0);
const discount = Number(cob.discount || 0);
const receivedValue = Number(payload.payment.value);
// Se o Asaas enviou o valor líquido (menor que o registrado),
// registramos como valor_pago e preservamos o valor da parcela
if (receivedValue < storedValor) {
updateData.valor_pago = receivedValue;
}
}
} catch (e) { console.error('[Webhook:Recovery] Erro:', e.message); }
if (payload.payment.transactionReceiptUrl) {
updateData.transaction_receipt_url = payload.payment.transactionReceiptUrl;
}
// Alerta no Sino (Admin)
createAdminNotification(
'✅ Pagamento Confirmado',
`Recebemos R$ ${Number(payload.payment.value).toFixed(2)} de ${studentDisplay}.`,
{ type: 'finance', status: 'paid', paymentId: asaasPaymentId }
);
sendEvolutionMessage(asaasPaymentId, 'PAYMENT_RECEIVED');
break;
case 'PAYMENT_OVERDUE':
updateData = { status: 'ATRASADO' };
// Alerta no Sino (Admin)
createAdminNotification(
'⚠️ Pagamento em Atraso',
`A cobrança de ${studentDisplay} no valor de R$ ${Number(payload.payment.value).toFixed(2)} está vencida.`,
{ type: 'finance', status: 'overdue', paymentId: asaasPaymentId }
);
sendEvolutionMessage(asaasPaymentId, 'PAYMENT_OVERDUE');
break;
case 'PAYMENT_UPDATED':
updateData = {
valor: payload.payment.value,
vencimento: payload.payment.dueDate,
link_boleto: payload.payment.bankSlipUrl || payload.payment.link || null
};
// Alerta no Sino (Admin)
createAdminNotification(
'📝 Cobrança Alterada',
`A cobrança de ${studentDisplay} foi atualizada no Asaas para R$ ${Number(payload.payment.value).toFixed(2)}.`,
{ type: 'finance', status: 'updated', paymentId: asaasPaymentId }
);
if (payload.event === 'PAYMENT_UPDATED') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_UPDATED');
break;
case 'PAYMENT_DELETED':
case 'PAYMENT_CANCELED':
// Alerta no Sino (Admin)
createAdminNotification(
'🗑️ Cobrança Removida',
`A cobrança de ${studentDisplay} (R$ ${Number(payload.payment.value).toFixed(2)}) foi excluída no Asaas.`,
{ type: 'finance', status: 'deleted', paymentId: asaasPaymentId }
);
const installmentId = payload.payment.installment;
if (installmentId) {
if (cancelCache.has(installmentId)) {
await deleteCobranca(asaasPaymentId);
return res.status(200).send('OK');
}
cancelCache.add(installmentId);
setTimeout(() => cancelCache.delete(installmentId), 60000);
}
const sent = await sendEvolutionMessage(asaasPaymentId, 'PAYMENT_DELETED');
await deleteCobranca(asaasPaymentId);
if (sent) {
createAdminNotification('✅ WhatsApp Enviado', `O aluno ${studentDisplay} foi notificado sobre o cancelamento da cobrança.`, { type: 'whatsapp', status: 'success' });
addLog('WhatsApp', 'Cancelamento Enviado', { aluno: targetName, asaasPaymentId });
} else {
createAdminNotification('⚠️ Falha no WhatsApp', `Não foi possível enviar a notificação de cancelamento para ${studentDisplay}.`, { type: 'whatsapp', status: 'error' });
addLog('WhatsApp', 'Erro no Cancelamento', { aluno: targetName, asaasPaymentId });
}
return res.status(200).send('OK');
default:
return res.status(200).json({ message: 'Evento ignorado' });
}
await updateCobranca(asaasPaymentId, updateData);
// Sincronização em tempo real com o JSON legado
try {
const appData = await getSchoolData();
const pIdx = appData.payments?.findIndex(p => p.asaasPaymentId === asaasPaymentId);
if (pIdx !== undefined && pIdx !== -1) {
const p = appData.payments[pIdx];
const statusStr = (updateData.status || '').toLowerCase();
const newStatus = statusStr === 'pago' ? 'paid' :
statusStr === 'atrasado' ? 'overdue' :
statusStr === 'cancelado' ? 'cancelled' : 'pending';
// Se for um evento de atualização de pagamento, atualiza o valor.
// Se for só confirmação de recebimento, preserva o 'amount' bruto original para não causar double-discount.
const shouldUpdateAmount = payload.event === 'PAYMENT_UPDATED' && updateData.valor;
appData.payments[pIdx] = {
...p,
status: newStatus,
amount: shouldUpdateAmount ? updateData.valor : p.amount,
valor_pago: updateData.valor_pago || p.valor_pago || 0,
dueDate: updateData.vencimento || p.dueDate,
paidDate: updateData.data_pagamento || p.paidDate
};
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
console.log(`[Webhook:Sync] JSON atualizado para boleto ${asaasPaymentId} (Amount: ${shouldUpdateAmount ? 'Atualizado' : 'Preservado'})`);
}
} catch (syncErr) {
console.error('[Webhook:Sync] Erro ao sincronizar JSON:', syncErr.message);
}
addLog('Webhook', `Sucesso ${payload.event}`, { asaasPaymentId });
return res.status(200).json({ message: 'OK' });
} catch (error) {
console.error('Webhook erro:', error);
return res.status(500).json({ error: 'Erro interno' });
}
});
// Admin Raw Cobrancas para a Aba Financeiro
app.get('/api/admin/cobrancas', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM alunos_cobrancas ORDER BY vencimento DESC');
// Garantir que valores numéricos sejam retornados como Number para evitar bugs de string no front
const rows = result.rows.map(r => ({
...r,
valor: Number(r.valor)
}));
res.json(rows);
} catch(e) {
res.status(500).json({error: e.message});
}
});
app.delete('/api/admin/cobrancas', async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids)) return res.status(400).end();
await pool.query('DELETE FROM alunos_cobrancas WHERE asaas_payment_id = ANY($1) OR local_id = ANY($1)', [ids]);
res.json({ success: true });
} catch(e) {
res.status(500).json({error: e.message});
}
});
app.delete('/api/admin/cobrancas/:id', async (req, res) => {
try {
await pool.query('DELETE FROM alunos_cobrancas WHERE asaas_payment_id = $1 OR local_id = $1', [req.params.id]);
res.json({ success: true });
} catch(e) {
res.status(500).json({error: e.message});
}
});
// Fase 2: Escrita dupla — Manager pode atualizar campos ricos diretamente no SQL
app.put('/api/admin/cobrancas/:id', async (req, res) => {
try {
const { valor, vencimento, description, type, discount, installment_number, total_installments, amount_original } = req.body;
const updates = [];
const values = [];
let paramIdx = 1;
if (valor !== undefined) { updates.push(`valor = $${paramIdx++}`); values.push(valor); }
if (vencimento !== undefined) { updates.push(`vencimento = $${paramIdx++}`); values.push(vencimento); }
if (description !== undefined) { updates.push(`description = $${paramIdx++}`); values.push(description); }
if (type !== undefined) { updates.push(`type = $${paramIdx++}`); values.push(type); }
if (discount !== undefined) { updates.push(`discount = $${paramIdx++}`); values.push(discount); }
if (installment_number !== undefined) { updates.push(`installment_number = $${paramIdx++}`); values.push(installment_number); }
if (total_installments !== undefined) { updates.push(`total_installments = $${paramIdx++}`); values.push(total_installments); }
if (amount_original !== undefined) { updates.push(`amount_original = $${paramIdx++}`); values.push(amount_original); }
if (updates.length === 0) return res.status(400).json({ error: 'Nenhum campo para atualizar.' });
values.push(req.params.id);
await pool.query(
`UPDATE alunos_cobrancas SET ${updates.join(', ')} WHERE asaas_payment_id = $${paramIdx} OR local_id = $${paramIdx}`,
values
);
res.json({ success: true });
} catch(e) {
console.error('[Admin:Cobrancas:PUT] Erro:', e.message);
res.status(500).json({ error: e.message });
}
});
// Webhook Evolution
app.post('/api/webhooks/evolution', (req, res) => {
try {
const payload = req.body;
let messageData = payload.data || payload;
if (messageData.status === 'READ') {
const phone = messageData.key?.remoteJid || 'Desconhecido';
console.log(`👀 [WhatsApp STATUS] Mensagem LIDA: ${phone.split('@')[0]}`);
}
res.status(200).send('OK');
} catch (err) {
res.status(500).send('Erro');
}
});
// ============================================================
// Gerar Cobrança
// ============================================================
app.post('/api/gerar_cobranca', async (req, res) => {
try {
const { aluno_id, nome, cpf, email, valor, vencimento, multa, juros, desconto, telefone, cep, endereco, numero, bairro, descricao, parcelas, nascimento } = req.body;
let customerId = '';
const searchRes = await fetch(`${ASAAS_BASE_URL}/v3/customers?cpfCnpj=${cpf}`, { method: 'GET', headers: { 'access_token': process.env.ASAAS_API_KEY } });
if (searchRes.ok) {
const searchData = await searchRes.json();
if (searchData.data?.length > 0) customerId = searchData.data[0].id;
}
if (!customerId) {
const customerRes = await fetch(`${ASAAS_BASE_URL}/v3/customers`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'access_token': process.env.ASAAS_API_KEY },
body: JSON.stringify({ name: nome, cpfCnpj: cpf, email, mobilePhone: telefone, postalCode: cep, address: endereco, addressNumber: numero, province: bairro, birthDate: nascimento })
});
if (!customerRes.ok) {
const errorData = await customerRes.json();
throw new Error(errorData.errors?.[0]?.description || 'Falha ao criar cliente');
}
customerId = (await customerRes.json()).id;
}
const asaasPayload = { customer: customerId, billingType: 'BOLETO', dueDate: vencimento, description: descricao ? `${descricao} - Microtec Informática Cursos` : 'Mensalidade - Microtec Informática Cursos' };
const isInstallment = parcelas && parseInt(parcelas) > 1;
if (isInstallment) { asaasPayload.installmentCount = parseInt(parcelas); asaasPayload.installmentValue = parseFloat(valor); }
else { asaasPayload.value = parseFloat(valor); }
const fineValue = parseFloat(multa); const interestValue = parseFloat(juros); const discountValue = parseFloat(desconto);
if (!isNaN(fineValue) && fineValue > 0) asaasPayload.fine = { value: fineValue, type: 'PERCENTAGE' };
if (!isNaN(interestValue) && interestValue > 0) asaasPayload.interest = { value: interestValue, type: 'PERCENTAGE' };
if (!isNaN(discountValue) && discountValue > 0) asaasPayload.discount = { value: discountValue, dueDateLimitDays: 0, type: 'FIXED' };
const paymentRes = await fetch(`${ASAAS_BASE_URL}/v3/payments`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'access_token': process.env.ASAAS_API_KEY }, body: JSON.stringify(asaasPayload) });
if (!paymentRes.ok) { const e = await paymentRes.json(); throw new Error(e.errors?.[0]?.description || 'Falha Asaas'); }
const paymentData = await paymentRes.json();
let paymentsToSave = [];
const instId = formatInstallmentId(paymentData.installment);
if (isInstallment && instId) {
const installmentsRes = await fetch(`${ASAAS_BASE_URL}/v3/payments?installment=${instId}&limit=100`, { headers: { 'access_token': process.env.ASAAS_API_KEY } });
if (installmentsRes.ok) {
const installmentsData = await installmentsRes.json();
paymentsToSave = installmentsData.data.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate)).map(p => ({
aluno_id, asaas_customer_id: customerId, asaas_payment_id: p.id, asaas_installment_id: instId, installment: instId, valor: p.value, vencimento: p.dueDate, link_boleto: p.bankSlipUrl
}));
} else throw new Error('Falha ao buscar parcelas');
} else {
paymentsToSave = [{ aluno_id, asaas_customer_id: customerId, asaas_payment_id: paymentData.id, installment: null, valor: paymentData.value || valor, vencimento: paymentData.dueDate || vencimento, link_boleto: paymentData.bankSlipUrl }];
}
await insertCobrancas(paymentsToSave);
if (paymentsToSave.length > 0) {
sendEvolutionMessage(paymentsToSave[0].asaas_payment_id, 'PAYMENT_CREATED').catch(e => console.error('Erro disparo:', e));
}
return res.status(200).json({ success: true, installment: instId || null, payments: paymentsToSave, bankSlipUrl: paymentsToSave[0]?.link_boleto, paymentId: paymentsToSave[0]?.asaas_payment_id });
} catch (error) {
console.error('Erro gerar cobrança:', error);
return res.status(500).json({ error: error.message });
}
});
// ============================================================
// Notificar Alunos sobre Avaliação
// ============================================================
app.post('/api/exames/notificar', async (req, res) => {
const { examId } = req.body;
if (!examId) return res.status(400).json({ error: 'ID do exame obrigatório.' });
try {
// 1. Buscar exame diretamente no banco SQL
const examResult = await pool.query('SELECT * FROM provas WHERE id = $1', [examId]);
const exam = examResult.rows[0];
if (!exam) return res.status(404).json({ error: 'Exame não encontrado.' });
// 2. Buscar a turma diretamente no banco SQL
const classResult = await pool.query('SELECT * FROM turmas WHERE id = $1', [exam.turma_id]);
const classObj = classResult.rows[0];
if (!classObj) return res.status(404).json({ error: 'Turma não encontrada.' });
// 3. Buscar a disciplina diretamente no banco SQL
let materia = 'sua disciplina';
if (exam.disciplina_id) {
const subjectResult = await pool.query('SELECT * FROM disciplinas WHERE id = $1', [exam.disciplina_id]);
if (subjectResult.rows[0]) {
materia = subjectResult.rows[0].nome;
}
}
// 4. Buscar alunos ativos vinculados a esta turma no banco SQL
const alunosResult = await pool.query('SELECT * FROM alunos WHERE turma_id = $1 AND status = $2', [classObj.id, 'active']);
const alunos = alunosResult.rows.map(a => ({
id: a.id,
name: a.nome,
phone: a.telefone,
guardianPhone: a.telefone_responsavel,
enrollmentNumber: a.numero_matricula
}));
if (alunos.length === 0) return res.status(400).json({ error: 'Nenhum aluno ativo nesta turma.' });
// 5. Buscar credenciais e templates na tabela configuracoes
const configResult = await pool.query('SELECT nome, evolution_api_url, evolution_instance_name, evolution_api_key, message_templates FROM configuracoes LIMIT 1');
const firstRow = configResult.rows[0];
if (!firstRow) return res.status(400).json({ error: 'Configurações de envio não encontradas.' });
const evoConfig = {
apiUrl: firstRow.evolution_api_url,
instanceName: firstRow.evolution_instance_name,
apiKey: firstRow.evolution_api_key
};
const templates = typeof firstRow.message_templates === 'string'
? JSON.parse(firstRow.message_templates)
: firstRow.message_templates || {};
const escolaNome = firstRow.nome || 'nossa escola';
const msgTemplate = templates.novaAvaliacao || "Olá {nome}, uma nova {tipo_avaliacao} ({titulo_avaliacao}) de {materia} foi publicada no portal do aluno. Acesse e realize o mais breve possível!";
const tipoAvaliacao = exam.evaluation_type === 'activity' ? 'atividade' : 'prova';
// 1. Inserir notificações no PostgreSQL (Sino do Portal)
for (const aluno of alunos) {
await pool.query(
`INSERT INTO notificacoes (aluno_id, titulo, mensagem, lida) VALUES ($1, $2, $3, false)`,
[aluno.id, "Nova Avaliação Disponível!", `A ${tipoAvaliacao} "${exam.titulo}" já está disponível no seu portal.`]
);
}
// 2. Disparo de WhatsApp em Background
if (evoConfig.apiUrl && evoConfig.apiKey && evoConfig.instanceName) {
// Background async function
(async () => {
for (let i = 0; i < alunos.length; i++) {
const aluno = alunos[i];
const telefone = aluno.phone || aluno.guardianPhone;
if (!telefone) continue;
let cleanPhone = telefone.replace(/\D/g, '');
if (cleanPhone.length === 10 || cleanPhone.length === 11) cleanPhone = '55' + cleanPhone;
const msg = msgTemplate
.replace(/{nome}/g, aluno.name.split(' ')[0])
.replace(/{matricula}/g, aluno.enrollmentNumber || '—')
.replace(/{tipo_avaliacao}/g, tipoAvaliacao)
.replace(/{titulo_avaliacao}/g, exam.titulo)
.replace(/{materia}/g, materia)
.replace(/{escola}/g, escolaNome);
try {
const url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/sendText/${evoConfig.instanceName}`;
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': evoConfig.apiKey }, body: JSON.stringify({ number: cleanPhone, text: msg }) });
} catch (error) {
console.error(`[Notificar Avaliação] Erro ${aluno.name}:`, error.message);
}
if (i < alunos.length - 1) await new Promise(r => setTimeout(r, Math.floor(Math.random() * 30000) + 15000));
}
})();
}
return res.status(200).json({ success: true, message: 'Notificações criadas e disparos iniciados.' });
} catch (error) {
console.error('Erro ao notificar exames:', error);
return res.status(500).json({ error: error.message });
}
});
// ============================================================
// Notificações do Sistema (Painel Admin)
// ============================================================
app.get('/api/notificacoes/admin', async (req, res) => {
try {
const { rows } = await pool.query(
'SELECT id, aluno_id as "studentId", titulo as title, mensagem as message, lida as read, anexo as attachment, created_at as "createdAt" FROM notificacoes WHERE aluno_id = $1 ORDER BY created_at DESC',
['admin']
);
res.json({ notifications: rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/notificacoes/ler/:id', async (req, res) => {
try {
const { id } = req.params;
await pool.query('UPDATE notificacoes SET lida = true WHERE id = $1', [id]);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/notificacoes/limpar-lidas', async (req, res) => {
try {
await pool.query('DELETE FROM notificacoes WHERE aluno_id = $1 AND lida = true', ['admin']);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/notificacoes/remover-anexo/:id', async (req, res) => {
try {
const { id } = req.params;
await pool.query('UPDATE notificacoes SET anexo = NULL WHERE id = $1', [id]);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============================================================
// Disparo em Massa
// ============================================================
app.post('/api/enviar-massa', upload.single('attachment'), (req, res) => {
let { alunos, mensagem, delay } = req.body;
if (typeof alunos === 'string') {
try { alunos = JSON.parse(alunos); } catch (e) { alunos = []; }
}
if (!alunos || !Array.isArray(alunos) || alunos.length === 0) return res.status(400).json({ error: 'Nenhum aluno.' });
res.status(200).json({ success: true, message: 'Background iniciado.' });
const fileData = req.file ? {
buffer: req.file.buffer.toString('base64'),
mimetype: req.file.mimetype,
originalname: req.file.originalname
} : null;
processarFilaWhatsApp(alunos, mensagem, parseInt(delay) || 60, fileData);
});
async function processarFilaWhatsApp(alunos, mensagemTemplate, customDelay = 60, fileData = null) {
const configResult = await pool.query('SELECT evolution_api_url, evolution_instance_name, evolution_api_key FROM configuracoes LIMIT 1');
const firstRow = configResult.rows[0];
if (!firstRow) return console.log('[WhatsApp:Massa] Configurações da Evolution API não encontradas no PostgreSQL.');
const evoConfig = {
apiUrl: firstRow.evolution_api_url,
instanceName: firstRow.evolution_instance_name,
apiKey: firstRow.evolution_api_key
};
if (!evoConfig.apiUrl || !evoConfig.apiKey || !evoConfig.instanceName) return;
for (let i = 0; i < alunos.length; i++) {
const aluno = alunos[i];
const msg = mensagemTemplate.replace(/{nome}/g, aluno.nome).replace(/{matricula}/g, aluno.matricula || '—');
try {
let cleanPhone = aluno.telefone.replace(/\D/g, '');
if (cleanPhone.length === 10 || cleanPhone.length === 11) cleanPhone = '55' + cleanPhone;
let url, payload;
if (fileData) {
url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/sendMedia/${evoConfig.instanceName}`;
payload = {
number: cleanPhone,
options: { delay: 1200, presence: "composing" },
mediatype: fileData.mimetype.includes('pdf') ? 'document' : 'image',
mimetype: fileData.mimetype,
fileName: fileData.originalname,
media: fileData.buffer,
caption: msg
};
} else {
url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/sendText/${evoConfig.instanceName}`;
payload = { number: cleanPhone, text: msg };
}
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': evoConfig.apiKey }, body: JSON.stringify(payload) });
} catch (error) { console.error(`[Massa] Erro ${aluno.nome}:`, error.message); }
if (i < alunos.length - 1) {
// Delay base informado pelo usuário + variância aleatória de 0-30s para evitar padrões robóticos
const delayMs = (customDelay * 1000) + (Math.floor(Math.random() * 30000));
await new Promise(r => setTimeout(r, delayMs));
}
}
}
// ============================================================
// Logs
// ============================================================
const apiLogs = [];
function addLog(service, action, details) {
apiLogs.unshift({ date: new Date().toISOString(), service, action, details });
if (apiLogs.length > 200) apiLogs.pop();
}
app.get('/api/logs', (req, res) => res.json(apiLogs));
const isUUID = (str) => typeof str === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str);
const formatInstallmentId = (id) => { if (!id) return id; if (id.startsWith('inst_')) return id.replace('inst_', 'ins_'); return id; };
// ============================================================
// Exclusão de Cobrança
// ============================================================
app.post('/api/excluir_cobranca', async (req, res) => {
try {
const { id } = req.body;
if (!id) return res.status(400).json({ error: 'ID não fornecido' });
const isManual = id.startsWith('pay-');
if (isManual) {
await pool.query('DELETE FROM alunos_cobrancas WHERE local_id = $1', [id]);
return res.status(200).json({ message: 'Excluído na base local' });
}
const parcelas = await getCobrancasByOrQuery(id);
let isSinglePayment = id.startsWith('pay_');
if (!isSinglePayment) {
const asaasTargetId = formatInstallmentId(id);
const resp = await fetch(`${ASAAS_BASE_URL}/v3/installments/${asaasTargetId}`, { method: 'DELETE', headers: { 'access_token': process.env.ASAAS_API_KEY } });
if (!resp.ok) { const e = await resp.json().catch(() => ({})); return res.status(400).json({ error: e.errors?.[0]?.description || 'Falha Asaas' }); }
addLog('Asaas', 'Exclusão Parcelamento OK', { id: asaasTargetId });
// Deletar localmente apenas via webhook para não apagar antes de enviar o WhatsApp (Regra 34)
} else {
const resp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${id}`, { method: 'DELETE', headers: { 'access_token': process.env.ASAAS_API_KEY } });
if (!resp.ok) { const e = await resp.json().catch(() => ({})); return res.status(400).json({ error: e.errors?.[0]?.description || 'Falha Asaas' }); }
addLog('Asaas', 'Exclusão Cobrança OK', { id });
// Deletar localmente apenas via webhook para não apagar antes de enviar o WhatsApp (Regra 34)
}
return res.status(200).json({ message: 'Exclusão solicitada ao Asaas. A remoção local ocorrerá via Webhook.' });
} catch (error) {
console.error('[Exclusão] Erro:', error);
return res.status(500).json({ error: 'Erro interno.' });
}
});
// ============================================================
// Carnês e Links
// ============================================================
app.get('/api/parcelamentos/:id/carne', async (req, res) => {
try {
const id = req.params.id;
const parcelas = await getCobrancasByOrQuery(id);
let instId = (!id.startsWith('pay_')) ? id : null;
if (!instId && parcelas?.length > 0) { const p = parcelas.find(x => x.asaas_installment_id); if (p) instId = p.asaas_installment_id; }
if (instId) {
const asaasTargetInstId = formatInstallmentId(instId);
const pSaved = parcelas?.find(x => x.link_carne);
if (pSaved?.link_carne) return res.status(200).json({ status: 'success', type: 'pdf', url: pSaved.link_carne });
const ar = await fetch(`${ASAAS_BASE_URL}/v3/installments/${asaasTargetInstId}/paymentBook`, { headers: { 'access_token': process.env.ASAAS_API_KEY, 'Accept': 'application/pdf' } });
if (ar.ok && ar.headers.get('content-type')?.includes('pdf')) {
const buffer = Buffer.from(await ar.arrayBuffer());
const fileName = `carne_${asaasTargetInstId}.pdf`;
const publicUrl = await uploadCarneToStorage(fileName, buffer);
await updateCobrancaLinkCarne(instId, publicUrl);
return res.status(200).json({ status: 'success', type: 'pdf', url: publicUrl });
}
}
const boletos = parcelas ? parcelas.map((c, i) => ({ id: c.id, numero: i + 1, vencimento: c.vencimento, valor: c.valor, linkBoleto: c.link_boleto, status: c.status, asaasPaymentId: c.asaas_payment_id })) : [];
return res.status(200).json({ status: 'success', type: 'fallback', boletos });
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
});
app.get('/api/cobrancas/:id/link', async (req, res) => {
try {
const p = await fetch(`${ASAAS_BASE_URL}/v3/payments/${req.params.id}`, { headers: { 'access_token': process.env.ASAAS_API_KEY } });
if (!p.ok) return res.status(404).json({ error: 'Não encontrada.' });
const d = await p.json();
return res.status(200).json({ bankSlipUrl: d.bankSlipUrl || d.invoiceUrl, transactionReceiptUrl: d.transactionReceiptUrl });
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
});
app.patch('/api/alunos/:id/rematricular', async (req, res) => {
try {
const { id } = req.params;
// 1. Update in Postgres
await pool.query("UPDATE alunos SET status = 'active', motivo_cancelamento = NULL WHERE id = $1", [id]);
// 2. Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbAlunos = await getAlunos();
appData.students = dbAlunos;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao rematricular aluno:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.put('/api/cobrancas/:id', async (req, res) => {
try {
const { id } = req.params;
const { valor, vencimento } = req.body;
let targetAsaasId = id;
if (isUUID(id)) {
const parcelas = await getCobrancasByOrQuery(id);
if (parcelas.length > 0 && parcelas[0].asaas_payment_id) targetAsaasId = parcelas[0].asaas_payment_id;
}
const isAsaasPayment = targetAsaasId && targetAsaasId.startsWith('pay_');
if (isAsaasPayment) {
const aResp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${targetAsaasId}`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'access_token': process.env.ASAAS_API_KEY },
body: JSON.stringify({ value: valor, dueDate: vencimento })
});
if (!aResp.ok) { const err = await aResp.json().catch(() => ({})); return res.status(400).json({ error: err.errors?.[0]?.description || 'Erro Asaas' }); }
}
const queryField = isUUID(id) ? 'id' : (id.startsWith('pay_') ? 'asaas_payment_id' : 'local_id');
await updateCobrancaByField(queryField, id, { valor, vencimento });
res.json({ message: 'Editado com sucesso' });
} catch (e) {
console.error('[cobrancas:PUT] Erro:', e);
res.status(500).json({ error: 'Erro interno.' });
}
});
app.get('/api/alunos/:id/carne', async (req, res) => {
try {
const cobrancas = await getCobrancasByAlunoId(req.params.id);
const withInstallment = cobrancas.filter(c => c.asaas_installment_id);
if (withInstallment.length === 0) return res.status(404).json({ error: 'Nenhum carnê.' });
const latestInstId = withInstallment[withInstallment.length - 1].asaas_installment_id;
const asaasTargetInstId = formatInstallmentId(latestInstId);
const binResp = await fetch(`${ASAAS_BASE_URL}/v3/installments/${asaasTargetInstId}/paymentBook`, { headers: { 'access_token': process.env.ASAAS_API_KEY, 'Accept': 'application/pdf' } });
if (binResp.ok && binResp.headers.get('content-type')?.includes('pdf')) {
const buffer = Buffer.from(await binResp.arrayBuffer());
const fileName = `carne_${asaasTargetInstId}.pdf`;
const publicUrl = await uploadCarneToStorage(fileName, buffer);
await updateCobrancaLinkCarne(latestInstId, publicUrl);
return res.status(200).json({ status: 'success', type: 'pdf', url: publicUrl });
}
const allCobs = await getCobrancasByInstallmentId(latestInstId);
const boletos = allCobs.map((c, i) => ({ id: c.id, numero: i + 1, vencimento: c.vencimento, valor: c.valor, linkBoleto: c.link_boleto, status: c.status, asaasPaymentId: c.asaas_payment_id }));
return res.status(200).json({ status: 'success', type: 'fallback', boletos });
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
});
// ============================================================
// INICIALIZAÇÃO
// ============================================================
// ============================================================
// LÓGICA REUTILIZÁVEL DE DISPARO DE COBRANÇAS
// ============================================================
// Helpers de Data
// ============================================================
const getLocalSafeDate = (val) => {
if (!val) return null;
const d = new Date(val);
if (isNaN(d.getTime())) return null;
// Se for string YYYY-MM-DD pura, o JS cria em UTC.
// Forçamos para os componentes locais para evitar o deslocamento de -1 dia.
if (typeof val === 'string' && val.includes('-') && !val.includes('T') && !val.includes(':')) {
const parts = val.split(' ')[0].split('-');
if (parts.length === 3) {
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0);
}
}
// Para objetos Date ou strings com tempo, extraímos o dia civil local
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
};
// ============================================================
async function executarRotinaCobrancas(tipo = 'ambos') {
const appData = await getSchoolData();
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;
const allPayments = appData.payments || [];
const hoje = getLocalSafeDate(new Date());
// 1. Processar Atrasados
if (tipo === 'atrasado' || tipo === 'ambos') {
const atrasados = allPayments.filter(p => (p.status || '').toUpperCase() === 'ATRASADO');
for (const pJSON of atrasados) {
if (!pJSON.asaasPaymentId || !pJSON.dueDate) continue;
// Pegamos os contadores do SQL para este boleto específico
const cob = await getCobrancaByPaymentId(pJSON.asaasPaymentId);
if (!cob) continue; // Sincronização cuidará de criar no SQL depois
const vencimento = getLocalSafeDate(pJSON.dueDate);
if (!vencimento) continue;
const diffDiasAtraso = Math.floor((hoje.getTime() - vencimento.getTime()) / (1000 * 60 * 60 * 24));
if (diffDiasAtraso >= sendDaysAfter) {
const lastWarn = getLocalSafeDate(cob.last_overdue_warning_at);
const diasDesdeUltimoAviso = lastWarn
? Math.floor((hoje.getTime() - lastWarn.getTime()) / (1000 * 60 * 60 * 24))
: null;
const jaEnviadoHoje = !rules.ignoreDailyLock && lastWarn && lastWarn.toDateString() === hoje.toDateString();
if (!jaEnviadoHoje && (diasDesdeUltimoAviso === null || diasDesdeUltimoAviso >= repeatEveryDays)) {
const sent = await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_OVERDUE', cob.valor, cob.vencimento);
if (sent) {
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 = allPayments.filter(p => ['PENDENTE', 'PENDING'].includes((p.status || '').toUpperCase()));
for (const pJSON of pendentes) {
if (!pJSON.asaasPaymentId || !pJSON.dueDate) continue;
// Pegamos os contadores do SQL para este boleto específico
const cob = await getCobrancaByPaymentId(pJSON.asaasPaymentId);
if (!cob) continue;
const vencimento = getLocalSafeDate(pJSON.dueDate);
if (!vencimento) continue;
const diffDias = Math.ceil((vencimento.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24));
const sendOnDueDate = rules.sendOnDueDate !== false;
if ((diffDias > 0 && diffDias <= sendDaysBefore) || (diffDias === 0 && sendOnDueDate)) {
const currentCount = parseInt(cob.pre_warnings_count) || 0;
if (currentCount < maxPreWarnings) {
const lastWarn = getLocalSafeDate(cob.last_pre_warning_at);
const jaEnviadoHoje = !rules.ignoreDailyLock && lastWarn && lastWarn.toDateString() === hoje.toDateString();
if (!jaEnviadoHoje) {
const sent = await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_UPCOMING', cob.valor, cob.vencimento);
if (sent) {
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 };
}
// ============================================================
// Rotina Automática de Aniversários
// ============================================================
async function executarRotinaAniversarios() {
try {
const configResult = await pool.query('SELECT nome, evolution_api_url, evolution_instance_name, evolution_api_key, message_templates FROM configuracoes LIMIT 1');
const firstRow = configResult.rows[0];
if (!firstRow) {
console.log('[Cron:Aniversário] ⚠️ Configurações não localizadas no PostgreSQL.');
return 0;
}
const evoConfig = {
apiUrl: firstRow.evolution_api_url,
instanceName: firstRow.evolution_instance_name,
apiKey: firstRow.evolution_api_key
};
if (!evoConfig.apiUrl || !evoConfig.apiKey || !evoConfig.instanceName) {
console.log('[Cron:Aniversário] ⚠️ Evolution API não configurada.');
return 0;
}
const templates = typeof firstRow.message_templates === 'string'
? JSON.parse(firstRow.message_templates)
: firstRow.message_templates || {};
const templateMsg = templates.felizAniversario || "Olá {nome}, a equipe da {escola} passa para te desejar um Feliz Aniversário! Muita saúde, paz e conquistas neste novo ciclo! 🎂🎈";
const escolaNome = firstRow.nome || '';
// Busca alunos de forma híbrida (JSON e SQL)
const { rows: sqlStudents } = await pool.query(`
SELECT id, nome as name, email, telefone as phone, data_nascimento, status, nome_responsavel as "guardianName", telefone_responsavel as "guardianPhone"
FROM alunos
WHERE status = 'active'
`);
const jsonStudents = appData.students || [];
const studentMap = new Map();
// Adiciona alunos do JSON
for (const s of jsonStudents) {
if (s.status === 'active' && s.id) {
studentMap.set(String(s.id), {
id: String(s.id),
name: s.name,
phone: s.phone,
guardianPhone: s.guardianPhone,
guardianName: s.guardianName,
birthDate: s.birthDate
});
}
}
// Sobrescreve/adiciona do SQL
for (const s of sqlStudents) {
if (s.id) {
let birthDateStr = null;
if (s.data_nascimento) {
const d = new Date(s.data_nascimento);
if (!isNaN(d.getTime())) {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
birthDateStr = `${year}-${month}-${day}`;
}
}
const existing = studentMap.get(String(s.id)) || {};
studentMap.set(String(s.id), {
id: String(s.id),
name: s.name || existing.name,
phone: s.phone || existing.phone,
guardianPhone: s.guardianPhone || existing.guardianPhone,
guardianName: s.guardianName || existing.guardianName,
birthDate: birthDateStr || existing.birthDate
});
}
}
// Filtra aniversariantes do dia
const today = new Date();
const todayDay = today.getDate();
const todayMonth = today.getMonth() + 1;
const aniversariantes = [];
for (const s of studentMap.values()) {
if (!s.birthDate) continue;
const parts = s.birthDate.split('-');
if (parts.length === 3) {
const bdayDay = parseInt(parts[2]);
const bdayMonth = parseInt(parts[1]);
if (bdayDay === todayDay && bdayMonth === todayMonth) {
aniversariantes.push(s);
}
}
}
if (aniversariantes.length === 0) {
console.log('[Cron:Aniversário] Nenhum aniversariante hoje.');
return 0;
}
console.log(`[Cron:Aniversário] 🎉 Encontrados ${aniversariantes.length} aniversariante(s) hoje.`);
let enviadas = 0;
for (let i = 0; i < aniversariantes.length; i++) {
const s = aniversariantes[i];
const nomePrimeiro = s.name.split(' ')[0];
const msg = templateMsg
.replace(/{nome}/g, nomePrimeiro)
.replace(/{escola}/g, escolaNome);
if (!s.phone) continue;
let cleanPhone = s.phone.replace(/\D/g, '');
if (cleanPhone.length === 10 || cleanPhone.length === 11) cleanPhone = '55' + cleanPhone;
try {
const url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/sendText/${evoConfig.instanceName}`;
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': evoConfig.apiKey
},
body: JSON.stringify({ number: cleanPhone, text: msg })
});
if (resp.ok) {
enviadas++;
} else {
console.error(`[Cron:Aniversário] Falha ao enviar para ${cleanPhone}: status ${resp.status}`);
}
} catch (err) {
console.error(`[Cron:Aniversário] Erro ao enviar para ${cleanPhone}:`, err.message);
}
if (i < aniversariantes.length - 1) {
await new Promise(r => setTimeout(r, 30000));
}
}
return enviadas;
} catch (error) {
console.error('[Cron:Aniversário] Erro na rotina de aniversário:', error.message);
return 0;
}
}
// ============================================================
// AGENDADOR AUTOMÁTICO (node-cron) — Suporte a múltiplos tipos
// ============================================================
function agendarRotina(tipo, hora, minuto) {
const label = tipo === 'preventivo' ? 'Preventivo' : tipo === 'atrasado' ? 'Inadimplência' : 'Aniversário';
// Cancela job anterior do mesmo tipo
if (tipo === 'preventivo' && activeCronJob) {
activeCronJob.stop();
activeCronJob = null;
console.log(`[Cron:${label}] ⏹ Rotina anterior cancelada.`);
} else if (tipo === 'atrasado' && activeCronJobOverdue) {
activeCronJobOverdue.stop();
activeCronJobOverdue = null;
console.log(`[Cron:${label}] ⏹ Rotina anterior cancelada.`);
} else if (tipo === 'aniversario' && activeCronJobBirthday) {
activeCronJobBirthday.stop();
activeCronJobBirthday = 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 cronExpression = `${m} ${h} * * *`;
const job = cron.schedule(cronExpression, async () => {
console.log(`[Cron:${label}] ⏰ Rotina automática iniciada às ${new Date().toLocaleTimeString('pt-BR')}`);
try {
if (tipo === 'aniversario') {
const count = await executarRotinaAniversarios();
console.log(`[Cron:${label}] ✅ Concluído: ${count} mensagens processadas.`);
} else {
const cronTipo = tipo === 'preventivo' ? 'preventivo' : 'atrasado';
const resultado = await executarRotinaCobrancas(cronTipo);
const count = tipo === 'preventivo' ? 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 (tipo === 'preventivo') activeCronJob = job;
else if (tipo === 'atrasado') activeCronJobOverdue = job;
else if (tipo === 'aniversario') activeCronJobBirthday = job;
console.log(`[Cron:${label}] ✅ Rotina agendada para ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')} (America/Sao_Paulo)`);
}
async function syncPaymentsWithAsaasAPI() {
try {
console.log(`[Asaas:Sync] 🚀 Iniciando Sincronização JSON-First...`);
// 1. Carregamos o JSON principal
const appData = await getSchoolData();
if (!appData || !appData.payments) {
console.error('[Asaas:Sync] ❌ JSON school_data ou payments não localizado.');
return 0;
}
// 2. URLs de busca (Usando a chave global ASAAS_KEY e ASAAS_BASE_URL)
const url = `${ASAAS_BASE_URL}/v3/payments?limit=100&status=RECEIVED&paymentDate%5Bge%5D=2026-01-01`;
const urlConfirmed = `${ASAAS_BASE_URL}/v3/payments?limit=100&status=CONFIRMED`;
const fetchPayments = async (targetUrl) => {
try {
const response = await fetch(targetUrl, {
headers: { 'access_token': process.env.ASAAS_API_KEY || ASAAS_KEY }
});
if (!response.ok) {
const errText = await response.text();
console.error(`[Asaas:Sync] Erro na API (${response.status}):`, errText);
return [];
}
const data = await response.json();
return data.data || [];
} catch (e) {
console.error(`[Asaas:Sync] Falha de rede na URL ${targetUrl}:`, e.message);
return [];
}
};
const received = await fetchPayments(url);
const confirmed = await fetchPayments(urlConfirmed);
const allRecent = [...received, ...confirmed];
if (allRecent.length === 0) {
console.log('[Asaas:Sync] Nenhum pagamento confirmado/recebido no Asaas.');
return 0;
}
const statusMap = {
'RECEIVED': 'PAGO',
'CONFIRMED': 'PAGO',
'RECEIVED_IN_CASH': 'PAGO',
'OVERDUE': 'ATRASADO',
'REFUNDED': 'CANCELADO'
};
const jsonStatusMap = {
'PAGO': 'paid',
'ATRASADO': 'overdue',
'CANCELADO': 'cancelled'
};
let totalUpdated = 0;
for (const payment of allRecent) {
const internalStatus = statusMap[payment.status];
if (!internalStatus) continue;
const valorNum = Number(payment.value);
// A. Atualiza SQL (Prioridade Máxima)
const receivedValue = (internalStatus === 'paid') ? valorNum : 0;
await pool.query(`
INSERT INTO alunos_cobrancas (asaas_payment_id, valor, vencimento, status, data_pagamento, valor_pago, amount_original)
VALUES ($1, $2, $3, $4, $5, $6, $2)
ON CONFLICT (asaas_payment_id) DO UPDATE SET
status = EXCLUDED.status,
data_pagamento = EXCLUDED.data_pagamento,
valor_pago = CASE WHEN EXCLUDED.valor_pago > 0 THEN EXCLUDED.valor_pago ELSE alunos_cobrancas.valor_pago END,
valor = COALESCE(NULLIF(EXCLUDED.valor, 0), alunos_cobrancas.valor)
`, [payment.id, valorNum, payment.dueDate, internalStatus, payment.confirmedDate || payment.paymentDate, receivedValue]).catch(() => {});
// B. Atualiza JSON
const pIdx = appData.payments.findIndex(p => p.asaasPaymentId === payment.id);
if (pIdx !== -1) {
const newStatus = jsonStatusMap[internalStatus];
const p = appData.payments[pIdx];
let changed = false;
if (p.status !== newStatus) {
p.status = newStatus;
changed = true;
}
// [Bugfix Crítico]: Não sobrescrever o valor BRUTO com o valor LÍQUIDO (descontado) do Asaas
const currentAmount = Number(p.amount || 0);
const currentDiscount = Number(p.discount || 0);
const isNetValueOverwrite = valorNum < currentAmount && Math.abs((currentAmount - currentDiscount) - valorNum) < 0.01;
if (p.amount !== valorNum && !isNetValueOverwrite) {
p.amount = valorNum;
changed = true;
}
// Adicionar valor_pago ao JSON para o Manager ler
if (receivedValue > 0 && Number(p.valor_pago || 0) !== receivedValue) {
p.valor_pago = receivedValue;
changed = true;
}
const newPaidDate = payment.confirmedDate || payment.paymentDate;
if (newPaidDate && p.paidDate !== newPaidDate) {
p.paidDate = newPaidDate;
changed = true;
}
if (changed) totalUpdated++;
}
}
if (totalUpdated > 0) {
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
console.log(`[Asaas:Sync] ✅ Sucesso! ${totalUpdated} pagamentos atualizados.`);
}
return totalUpdated;
} catch (err) {
console.error('[Asaas:Sync] ❌ Erro Fatal:', err.message);
throw err;
}
}
async function syncRelationalToJsonPayments() {
try {
const { rows: cloudPayments } = await pool.query('SELECT * FROM alunos_cobrancas');
const appData = await getSchoolData();
let updatedCount = 0;
if (!appData || !appData.payments) return;
const updatedPayments = appData.payments.map(p => {
const match = cloudPayments.find(cp => {
if (p.asaasPaymentId && cp.asaas_payment_id === p.asaasPaymentId) return true;
if (p.id && cp.local_id === p.id) return true;
return false;
});
if (match) {
const statusStr = (match.status || '').toLowerCase();
const newStatus = statusStr === 'pago' ? 'paid' :
statusStr === 'atrasado' ? 'overdue' :
statusStr === 'cancelado' ? 'cancelled' : 'pending';
const hasChanges = p.status !== newStatus ||
Number(p.valor_pago || 0) !== Number(match.valor_pago || 0) ||
Number(p.amount || 0) !== Number(match.valor || 0);
if (hasChanges) {
updatedCount++;
return {
...p,
status: newStatus,
paidDate: match.data_pagamento || p.paidDate,
valor_pago: Number(match.valor_pago || 0),
amount: Number(match.valor || p.amount || 0)
};
}
}
return p;
});
if (updatedCount > 0) {
appData.payments = updatedPayments;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
console.log(`[Sync:SQL->JSON] ✅ ${updatedCount} status de pagamentos sincronizados com sucesso.`);
}
return updatedCount;
} catch (err) {
console.error('[Sync:SQL->JSON] ❌ Erro na sincronização reversa:', err.message);
return 0;
}
}
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;
-- ===== FASE 1: Colunas ricas para migração financeira SQL-First =====
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='description') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN description TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='type') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN type TEXT DEFAULT 'monthly';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='discount') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN discount NUMERIC(10,2) DEFAULT 0;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='installment_number') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN installment_number INTEGER;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='total_installments') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN total_installments INTEGER;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='contract_id') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN contract_id TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='asaas_payment_url') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN asaas_payment_url TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='amount_original') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN amount_original NUMERIC(10,2);
END IF;
-- Garantir índice de unicidade para o UPSERT funcionar
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'alunos_cobrancas' AND indexname = 'idx_asaas_payment_id_unique') THEN
CREATE UNIQUE INDEX idx_asaas_payment_id_unique ON alunos_cobrancas(asaas_payment_id);
END IF;
END $$;
`).catch(err => console.error('[PostgreSQL] Erro boot automação:', err));
// Inicialização da Tabela de Notas e Migração Automática
await initNotasTable();
// Sincronização de Integridade (JSON -> Tabelas Relacionais)
await syncJsonToRelationalTables();
// Sincronização Reversa (SQL -> JSON) - Garante que status pagos no DB reflitam no painel administrativo
await syncRelationalToJsonPayments();
const appData = await getSchoolData();
// Migração: Se existirem notas no JSON, movemos para a tabela e removemos do JSON
if (appData && appData.grades && appData.grades.length > 0) {
console.log(`[Migração] Migrando ${appData.grades.length} notas do JSON para o PostgreSQL...`);
for (const grade of appData.grades) {
try {
await upsertNota({
aluno_id: String(grade.studentId),
disciplina_id: String(grade.subjectId),
periodo_id: String(grade.period),
prova_id: grade.examId ? String(grade.examId) : null,
valor: Number(grade.value)
});
} catch(err) {
console.error('[Migração] Erro ao migrar nota:', err);
}
}
appData.grades = []; // Limpa o JSON após migrar
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
console.log('[Migração] Migração de notas concluída com sucesso!');
}
const configResult = await pool.query('SELECT message_templates FROM configuracoes LIMIT 1');
const firstRow = configResult.rows[0];
const templates = firstRow && typeof firstRow.message_templates === 'string'
? JSON.parse(firstRow.message_templates)
: (firstRow?.message_templates || {});
const rules = templates.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.');
}
// Aniversário
if (rules.autoScheduleBirthdayEnabled && rules.autoScheduleBirthdayTime) {
const [h, m] = rules.autoScheduleBirthdayTime.split(':');
agendarRotina('aniversario', h, m);
} else {
console.log('[Cron:Aniversário] Agendamento desativado.');
}
} catch (e) {
console.error('[Cron] Erro ao inicializar agendamento:', e.message);
}
}
async function startServer() {
// Rota para zerar contadores de avisos
app.post('/api/admin/reset-cobrancas-counters', async (req, res) => {
try {
await pool.query('UPDATE alunos_cobrancas SET pre_warnings_count = 0, last_pre_warning_at = NULL, overdue_warnings_count = 0, last_overdue_warning_at = NULL');
return res.json({ success: true, message: 'Contadores zerados com sucesso!' });
} catch (e) {
return res.status(500).json({ error: e.message });
}
});
// Disparo Manual de Inadimplência e Lembretes
app.post('/api/disparar_cobrancas', async (req, res) => {
try {
const tipo = req.query.tipo || 'ambos';
const resultado = await executarRotinaCobrancas(tipo);
let msg = '';
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) {
console.error('[Disparo] Erro:', error);
return res.status(500).json({ error: 'Erro interno.' });
}
});
// Endpoint para forçar sincronização direta com a API do Asaas (Aba Financeiro)
app.post('/api/admin/sync-asaas-full', async (req, res) => {
try {
const updatedCount = await syncPaymentsWithAsaasAPI();
const appData = await getSchoolData(); // Busca o JSON já atualizado
res.json({ success: true, updatedCount, data: appData });
} catch (e) {
console.error('[Asaas:FullSync] Erro:', e.message);
res.status(500).json({ error: e.message });
}
});
// Endpoint para forçar sincronização SQL -> JSON (Aba Financeiro)
app.post('/api/admin/sync-finance-json', async (req, res) => {
try {
const updatedCount = await syncRelationalToJsonPayments();
res.json({ success: true, updatedCount });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// API para gerenciar o agendamento (suporte a preventivo, atrasado e aniversário)
app.get('/api/cron/status', (req, res) => {
res.json({
preventive: !!activeCronJob,
overdue: !!activeCronJobOverdue,
birthday: !!activeCronJobBirthday
});
});
app.post('/api/cron/schedule', async (req, res) => {
try {
const { enabled, time, tipo } = req.body;
// 1. Obter message_templates atual da tabela configuracoes
const configResult = await pool.query('SELECT id, message_templates FROM configuracoes LIMIT 1');
const firstRow = configResult.rows[0];
const templates = firstRow && typeof firstRow.message_templates === 'string'
? JSON.parse(firstRow.message_templates)
: (firstRow?.message_templates || {});
if (!templates.automationRules) templates.automationRules = {};
if (tipo === 'atrasado') {
templates.automationRules.autoScheduleOverdueEnabled = !!enabled;
templates.automationRules.autoScheduleOverdueTime = time || '09:00';
} else if (tipo === 'aniversario') {
templates.automationRules.autoScheduleBirthdayEnabled = !!enabled;
templates.automationRules.autoScheduleBirthdayTime = time || '09:00';
} else {
templates.automationRules.autoScheduleEnabled = !!enabled;
templates.automationRules.autoScheduleTime = time || '09:00';
}
// 2. Gravar o message_templates atualizado de volta no PostgreSQL
await pool.query(
'UPDATE configuracoes SET message_templates = $1, updated_at = NOW() WHERE id = $2',
[JSON.stringify(templates), firstRow?.id || 'main-school']
);
// Sincronização opcional com school_data
try {
const appData = await getSchoolData();
if (appData) {
appData.messageTemplates = templates;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
}
} catch (e) {}
if (enabled && time) {
const [h, m] = time.split(':');
agendarRotina(tipo, h, m);
} else {
if (tipo === 'atrasado') {
if (activeCronJobOverdue) { activeCronJobOverdue.stop(); activeCronJobOverdue = null; }
} else if (tipo === 'aniversario') {
if (activeCronJobBirthday) { activeCronJobBirthday.stop(); activeCronJobBirthday = null; }
} else {
if (activeCronJob) { activeCronJob.stop(); activeCronJob = null; }
}
}
res.json({
success: true,
preventive: !!activeCronJob,
overdue: !!activeCronJobOverdue,
birthday: !!activeCronJobBirthday
});
} catch (e) {
console.error('[Cron] Erro ao salvar agendamento:', e);
res.status(500).json({ error: 'Erro interno.' });
}
});
// Imprimir Carnê
app.get('/api/imprimir-carne/:installmentId', async (req, res) => {
try {
const { installmentId } = req.params;
const parcelas = await getCobrancasByOrQuery(installmentId);
let instId = (!installmentId.startsWith('pay_')) ? installmentId : null;
if (!instId && parcelas?.length > 0) { const p = parcelas.find(x => x.asaas_installment_id); if (p) instId = p.asaas_installment_id; }
const asaasTargetInstId = formatInstallmentId(instId || installmentId);
const pSaved = parcelas?.find(x => x.link_carne);
if (pSaved?.link_carne) return res.redirect(pSaved.link_carne);
let asaasUrl = `${ASAAS_BASE_URL}/v3/installments/${asaasTargetInstId}/paymentBook`;
const { sort, order } = req.query;
const params = new URLSearchParams();
if (sort) params.append('sort', sort);
if (order) params.append('order', order);
if (params.toString()) asaasUrl += `?${params.toString()}`;
const response = await fetch(asaasUrl, { headers: { 'access_token': process.env.ASAAS_API_KEY, 'Accept': 'application/pdf' } });
if (response.ok && response.headers.get('content-type')?.includes('pdf')) {
const buffer = Buffer.from(await response.arrayBuffer());
const fileName = `carne_${asaasTargetInstId}.pdf`;
// Upload assíncrono para MinIO
uploadCarneToStorage(fileName, buffer).then(publicUrl => {
updateCobrancaLinkCarne(instId, publicUrl).catch(() => {});
}).catch(() => {});
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="carne.pdf"');
return res.send(buffer);
} else {
return res.status(response.status).send('Falha Asaas');
}
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
});
// ===================================================
// PRÉ-MATRÍCULA — ROTAS CRUD (SQL-First)
// ===================================================
// Config
app.get('/api/prematricula/config', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM prematricula_config WHERE id = 1');
const r = rows[0];
if (!r) return res.json({ config: null });
res.json({ config: {
id: r.id, titulo: r.titulo, descricao: r.descricao, slug: r.slug,
status: r.status, corPrimaria: r.cor_primaria, logoUrl: r.logo_url,
mensagemSucesso: r.mensagem_sucesso,
turmasPermitidas: r.turmas_permitidas || [],
exibirEmail: r.exibir_email !== false,
exibirTelefone: r.exibir_telefone !== false,
bannerUrl: r.banner_url || ''
}});
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
});
app.put('/api/prematricula/config', async (req, res) => {
try {
const c = req.body;
await pool.query(
`UPDATE prematricula_config SET titulo=$1, descricao=$2, slug=$3, status=$4,
cor_primaria=$5, logo_url=$6, mensagem_sucesso=$7, turmas_permitidas=$8,
exibir_email=$9, exibir_telefone=$10, banner_url=$11, updated_at=NOW() WHERE id=1`,
[
c.titulo, c.descricao, c.slug, c.status, c.corPrimaria || '#4f46e5',
c.logoUrl || '', c.mensagemSucesso || '', c.turmasPermitidas || [],
c.exibirEmail !== false, c.exibirTelefone !== false, c.bannerUrl || ''
]
);
res.json({ success: true });
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
});
// Campos
app.get('/api/prematricula/campos', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM prematricula_campos ORDER BY ordem ASC');
const active = rows.filter(r => r.ativo !== false);
res.json({
campos: active.map(r => ({
id: r.id, label: r.label, tipo: r.tipo, placeholder: r.placeholder,
obrigatorio: r.obrigatorio, opcoes: r.opcoes || [], ordem: r.ordem, ativo: r.ativo
})),
todosCampos: rows.map(r => ({
id: r.id, label: r.label, tipo: r.tipo, placeholder: r.placeholder,
obrigatorio: r.obrigatorio, opcoes: r.opcoes || [], ordem: r.ordem, ativo: r.ativo
}))
});
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
});
app.put('/api/prematricula/campos/reordenar', async (req, res) => {
try {
const { ordem } = req.body;
if (!Array.isArray(ordem)) return res.status(400).json({ error: 'Formato inválido.' });
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const item of ordem) {
await client.query('UPDATE prematricula_campos SET ordem = $1 WHERE id = $2', [item.ordem, item.id]);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
res.json({ success: true });
} catch (e) {
console.error(e);
res.status(500).json({ error: e.message });
}
});
app.post('/api/prematricula/campos', async (req, res) => {
try {
const c = req.body;
await pool.query(
`INSERT INTO prematricula_campos (id, label, tipo, placeholder, obrigatorio, opcoes, ordem, ativo)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[c.id, c.label, c.tipo, c.placeholder || '', c.obrigatorio || false, c.opcoes || [], c.ordem || 0, true]
);
res.json({ success: true });
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
});
app.put('/api/prematricula/campos/:id', async (req, res) => {
try {
const c = req.body;
await pool.query(
`UPDATE prematricula_campos SET label=$1, tipo=$2, placeholder=$3, obrigatorio=$4, opcoes=$5, ordem=$6 WHERE id=$7`,
[c.label, c.tipo, c.placeholder, c.obrigatorio, c.opcoes || [], c.ordem, req.params.id]
);
res.json({ success: true });
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
});
app.delete('/api/prematricula/campos/:id', async (req, res) => {
try {
await pool.query('UPDATE prematricula_campos SET ativo = false WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
});
// Inscrições
app.get('/api/prematricula/inscricoes', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM prematriculas ORDER BY created_at DESC');
res.json({ inscricoes: rows.map(r => ({
id: r.id, nome: r.nome, email: r.email, telefone: r.telefone,
turmaId: r.turma_id, turmaNome: r.turma_nome, respostas: r.respostas || {},
status: r.status, observacoes: r.observacoes, createdAt: r.created_at
}))});
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
});
app.delete('/api/prematricula/inscricoes/:id', async (req, res) => {
try {
await pool.query('DELETE FROM prematriculas WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
});
// Upload público de arquivos de pré-matrícula para o MinIO
const prematriculaUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 } // 10MB
});
app.post('/api/prematricula/upload', prematriculaUpload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
}
const fileName = `pre_${Date.now()}_${Math.random().toString(36).substring(7)}_${req.file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
const url = await uploadFile('prematriculas', fileName, req.file.buffer, req.file.mimetype);
res.json({ url });
} catch (e) {
console.error('[PreMatricula:Upload] Erro de upload:', e);
res.status(500).json({ error: 'Erro ao fazer upload do arquivo no storage local.' });
}
});
// Submissão pública
app.post('/api/prematricula/submit', async (req, res) => {
try {
const { nome, email, telefone, turmaId, turmaNome, respostas } = req.body;
if (!nome) return res.status(400).json({ error: 'Nome é obrigatório.' });
const config = (await pool.query('SELECT status FROM prematricula_config WHERE id = 1')).rows[0];
if (!config || config.status !== 'published') return res.status(403).json({ error: 'Formulário não disponível.' });
// Impedir duplicidade por e-mail ou telefone
if ((email && email.trim()) || (telefone && telefone.trim())) {
let dupQuery = 'SELECT id FROM prematriculas WHERE 1=0';
const params = [];
if (email && email.trim()) {
dupQuery += ' OR LOWER(email) = LOWER($' + (params.length + 1) + ')';
params.push(email.trim());
}
if (telefone && telefone.trim()) {
const cleanPhone = telefone.replace(/\D/g, '');
if (cleanPhone) {
dupQuery += " OR REGEXP_REPLACE(telefone, '\\D', '', 'g') = $" + (params.length + 1);
params.push(cleanPhone);
}
}
const { rows: dupRows } = await pool.query(dupQuery, params);
if (dupRows.length > 0) {
return res.status(400).json({ error: 'Você já realizou uma pré-matrícula com esses dados de contato.' });
}
}
// Validação rigorosa de vagas
if (turmaId) {
const { rows: tRows } = await pool.query('SELECT vagas_prematricula FROM turmas WHERE id = $1', [turmaId]);
if (tRows.length > 0 && tRows[0].vagas_prematricula !== null) {
const limit = tRows[0].vagas_prematricula;
const { rows: countRows } = await pool.query("SELECT COUNT(*) as count FROM prematriculas WHERE status != 'rejeitado' AND turma_id = $1", [turmaId]);
const count = parseInt(countRows[0].count);
if (count >= limit) {
return res.status(400).json({ error: 'Desculpe, esta turma já preencheu todas as vagas de pré-matrícula.' });
}
}
}
const id = crypto.randomUUID();
await pool.query(
`INSERT INTO prematriculas (id, nome, email, telefone, turma_id, turma_nome, respostas, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pendente')`,
[id, nome, email || '', telefone || '', turmaId || null, turmaNome || '', JSON.stringify(respostas || {})]
);
res.json({ success: true, id });
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
});
async function handlePublicPreMatricula(req, res, slug) {
try {
const { rows: cfgRows } = await pool.query(
'SELECT * FROM prematricula_config WHERE (slug = $1 OR id = 1) ORDER BY id ASC LIMIT 1',
[slug]
);
if (cfgRows.length === 0) return res.status(404).json({ error: 'Formulário não encontrado.' });
const cfg = cfgRows[0];
if (cfg.status === 'draft') {
return res.json({ draft: true });
}
const { rows: camposRows } = await pool.query('SELECT * FROM prematricula_campos WHERE ativo = true ORDER BY ordem ASC');
// Buscar contagens de pré-matrículas ativas por turma para calcular as vagas restantes
const countsResult = await pool.query("SELECT turma_id, COUNT(*) as count FROM prematriculas WHERE status != 'rejeitado' GROUP BY turma_id");
const prematriculaCounts = {};
countsResult.rows.forEach(r => {
if (r.turma_id) {
prematriculaCounts[r.turma_id] = parseInt(r.count);
}
});
const turmasResult = await pool.query('SELECT id, nome, vagas_prematricula, horario, dia_semana, horario_inicio_padrao, horario_fim_padrao FROM turmas ORDER BY nome ASC');
let turmasRows = turmasResult.rows;
if (cfg.turmas_permitidas && cfg.turmas_permitidas.length > 0) {
turmasRows = turmasRows.filter(t => cfg.turmas_permitidas.includes(t.id));
}
const configResult = await pool.query('SELECT nome, logo FROM configuracoes LIMIT 1');
const firstRow = configResult.rows[0];
const escolaNome = firstRow?.nome || 'EduManager';
const escolaLogo = firstRow?.logo || '';
res.json({
config: {
titulo: cfg.titulo, descricao: cfg.descricao, corPrimaria: cfg.cor_primaria,
logoUrl: cfg.logo_url, mensagemSucesso: cfg.mensagem_sucesso,
bannerUrl: cfg.banner_url
},
campos: camposRows.map(r => ({
id: r.id, label: r.label, tipo: r.tipo, placeholder: r.placeholder,
obrigatorio: r.obrigatorio, opcoes: r.opcoes || []
})),
turmas: turmasRows.map(t => {
const count = prematriculaCounts[t.id] || 0;
const vagas = t.vagas_prematricula;
const esgotado = vagas !== null && count >= vagas;
const horarioStr = t.horario_inicio_padrao && t.horario_fim_padrao
? `${t.horario_inicio_padrao} às ${t.horario_fim_padrao}`
: '';
return {
id: t.id,
nome: t.nome,
horario: horarioStr,
vagas: vagas,
vagasRestantes: vagas !== null ? Math.max(0, vagas - count) : null,
esgotado: esgotado
};
}),
escola: { nome: escolaNome, logo: escolaLogo }
});
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
}
// Rota sem slug (usa slug padrão)
app.get('/api/prematricula/public', (req, res) => handlePublicPreMatricula(req, res, 'pre-matricula'));
// Rota com slug explícito
app.get('/api/prematricula/public/:slug', (req, res) => handlePublicPreMatricula(req, res, req.params.slug));
// Middleware para servir a página HTML pública de forma 100% dinâmica baseada na slug do banco
app.use(async (req, res, next) => {
// Ignorar APIs, Storage ou arquivos de assets estáticos (com extensão)
if (req.path.startsWith('/api') || req.path.startsWith('/storage') || req.path.includes('.')) {
return next();
}
const slug = req.path.substring(1); // Remove a primeira barra
if (!slug) return next();
try {
// Verifica se a slug atual corresponde a alguma configuração de pré-matrícula publicada
const { rows } = await pool.query(
'SELECT slug FROM prematricula_config WHERE slug = $1 LIMIT 1',
[slug]
);
if (rows.length > 0) {
// Se bater, serve a página pública renderizando a slug dinâmica!
return res.send(getPreMatriculaHTML(slug));
}
} catch (e) {
console.error('[PreMatricula:Router] Erro de roteamento dinâmico:', e);
}
next();
});
// ===================================================
// SERVE FRONTEND (Final Catch-all)
// ===================================================
const distPath = path.join(__dirname, 'dist');
if (fs.existsSync(distPath)) {
app.use(express.static(distPath));
app.use((req, res, next) => {
if (req.path.startsWith('/api') || req.path.startsWith('/storage')) return next();
res.sendFile(path.join(distPath, 'index.html'));
});
} else {
try {
const vite = await import('vite').then(m => m.createServer({
server: { middlewareMode: true },
appType: 'spa'
}));
app.use(vite.middlewares);
} catch (e) {
console.warn('Vite dev server not available and dist folder missing.');
}
}
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();