edumanagerpro2/manager/server.selfhosted.js

2133 lines
89 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';
// === 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
} from './services/database.js';
import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage, uploadReceipt as uploadReceiptToStorage, getMinioStats, s3Client, getBucketObjects, deleteMinioObject } 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();
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();
// 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' });
}
});
// ============================================================
// 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 appData = await getSchoolData();
if (!appData) return console.log('[WhatsApp] school_data não encontrado');
const evoConfig = appData.evolutionConfig;
const templates = appData.messageTemplates;
if (!evoConfig || !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);
let aluno = appData.students?.find(s => s.id === cob.aluno_id);
// Fallback: Se não achar no JSON, busca na tabela SQL de alunos
if (!aluno) {
const { rows } = await pool.query('SELECT * FROM alunos WHERE id = $1', [cob.aluno_id]);
if (rows[0]) {
const a = rows[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.telefone_responsavel || aluno.phone || aluno.telefone)
: (aluno.phone || aluno.telefone || aluno.guardianPhone || aluno.telefone_responsavel);
const targetName = (isMinor && (aluno.nome_responsavel || aluno.guardianName)) ? (aluno.nome_responsavel || aluno.guardianName) : (aluno.name || aluno.nome);
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 || aluno.nome)
.replace(/{matricula}/g, aluno.enrollmentNumber || aluno.numero_matricula || aluno.matricula || '—')
.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 {
const doc = new jsPDF();
const profile = appData.profile || {};
// Moldura e Cabeçalho
doc.setDrawColor(0);
doc.setLineWidth(0.5);
doc.rect(10, 10, 190, 100);
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text(profile.name || 'EduManager School', 105, 25, { align: 'center' });
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text(`CNPJ: ${profile.cnpj || '---'} | Contato: ${profile.phone || ''}`, 105, 32, { align: 'center' });
doc.setFontSize(16);
doc.text('RECIBO DE PAGAMENTO', 105, 50, { align: 'center' });
doc.setFontSize(11);
doc.text(`Recebemos de: ${aluno.name || aluno.nome}`, 20, 65);
doc.text(`A quantia de: R$ ${valorFormatado}`, 20, 75);
doc.text(`Referente a: ${descricao}`, 20, 85);
const dataHj = new Date().toLocaleDateString('pt-BR');
const dataPagamento = fallbackVencimento ? formatCobrancaDate(typeof fallbackVencimento === 'string' ? fallbackVencimento : dataHj) : dataHj;
doc.text(`Data do Pagamento: ${dataPagamento}`, 20, 95);
doc.line(60, 105, 150, 105);
doc.setFontSize(8);
doc.text('Autenticação Digital EduManager', 105, 108, { align: 'center' });
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;
let updateData = {};
const targetName = payload.payment.customerName || 'Cliente';
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]
};
// [Bugfix Crítico]: Recuperar o valor BRUTO caso o Asaas mande o líquido
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 currentAmount = Number(cob.valor || 0);
const discount = Number(cob.discount || 0);
const amountOriginal = Number(cob.amount_original || 0);
const receivedValue = Number(payload.payment.value);
// Se o valor recebido for menor que o bruto registrado e a diferença bater com o desconto,
// mantemos o bruto no campo 'valor'.
if (receivedValue < currentAmount && Math.abs((currentAmount - discount) - receivedValue) < 0.01) {
// Já está correto (bruto > recebido), não mexemos no 'valor'
} else if (receivedValue === currentAmount && discount > 0) {
// Se o 'valor' no banco já era o líquido, restauramos para o bruto
updateData.valor = receivedValue + discount;
} else if (amountOriginal > receivedValue) {
updateData.valor = amountOriginal;
}
}
} 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 ${targetName}.`,
{ 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 ${targetName} 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 ${targetName} 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 ${targetName} (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 ${targetName} 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 ${targetName}.`, { 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)', [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', [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}`,
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 {
const appData = await getSchoolData();
const exam = (appData.exams || []).find(e => e.id === examId);
if (!exam) return res.status(404).json({ error: 'Exame não encontrado.' });
const classObj = (appData.classes || []).find(c => c.id === exam.classId);
if (!classObj) return res.status(404).json({ error: 'Turma não encontrada.' });
const subjectObj = (appData.subjects || []).find(s => s.id === exam.subjectId);
const materia = subjectObj ? subjectObj.name : 'sua disciplina';
const alunos = (appData.students || []).filter(s => s.classId === classObj.id && s.status === 'active');
if (alunos.length === 0) return res.status(400).json({ error: 'Nenhum aluno ativo nesta turma.' });
const evoConfig = appData.evolutionConfig;
const msgTemplate = (appData.messageTemplates?.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.evaluationType === '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.title}" 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.title)
.replace(/{materia}/g, materia)
.replace(/{escola}/g, appData.profile?.name || 'nossa escola');
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 appData = await getSchoolData();
const evoConfig = appData?.evolutionConfig;
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 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) {
addLog('Asaas', 'Exclusão Parcelamento OK', { id: asaasTargetId });
}
} 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 });
}
return res.status(200).json({ message: 'Excluído no Asaas e na base local' });
} 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) => res.json({ success: true }));
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 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' : 'asaas_payment_id';
await updateCobrancaByField(queryField, id, { valor, vencimento });
res.json({ message: 'Editado com sucesso' });
} catch (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 appData = await getSchoolData();
if (!appData) return 0;
const evoConfig = appData.evolutionConfig;
if (!evoConfig?.apiUrl || !evoConfig?.apiKey || !evoConfig?.instanceName) {
console.log('[Cron:Aniversário] ⚠️ Evolution API não configurada.');
return 0;
}
const templates = appData.messageTemplates || {};
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 = appData.profile?.name || '';
// 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 = GREATEST(alunos_cobrancas.valor_pago, EXCLUDED.valor_pago),
valor = GREATEST(alunos_cobrancas.valor, EXCLUDED.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 => cp.asaas_payment_id === p.asaasPaymentId);
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) !== Math.max(Number(match.amount_original || 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: Math.max(Number(match.amount_original || 0), Number(match.valor || 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.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 rules = appData?.messageTemplates?.automationRules || {};
// Preventivo
if (rules.autoScheduleEnabled && rules.autoScheduleTime) {
const [h, m] = rules.autoScheduleTime.split(':');
agendarRotina('preventivo', h, m);
} else {
console.log('[Cron:Preventivo] Agendamento desativado.');
}
// Inadimplência
if (rules.autoScheduleOverdueEnabled && rules.autoScheduleOverdueTime) {
const [h, m] = rules.autoScheduleOverdueTime.split(':');
agendarRotina('atrasado', h, m);
} else {
console.log('[Cron:Inadimplência] Agendamento desativado.');
}
// 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;
const appData = await getSchoolData();
if (!appData.messageTemplates) appData.messageTemplates = {};
if (!appData.messageTemplates.automationRules) appData.messageTemplates.automationRules = {};
if (tipo === 'atrasado') {
appData.messageTemplates.automationRules.autoScheduleOverdueEnabled = !!enabled;
appData.messageTemplates.automationRules.autoScheduleOverdueTime = time || '09:00';
} else if (tipo === 'aniversario') {
appData.messageTemplates.automationRules.autoScheduleBirthdayEnabled = !!enabled;
appData.messageTemplates.automationRules.autoScheduleBirthdayTime = time || '09:00';
} else {
appData.messageTemplates.automationRules.autoScheduleEnabled = !!enabled;
appData.messageTemplates.automationRules.autoScheduleTime = time || '09:00';
}
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
if (enabled && time) {
const [h, m] = time.split(':');
agendarRotina(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 (error) {
console.error('[Cron] Erro ao salvar agendamento:', error);
res.status(500).json({ error: 'Erro interno.' });
}
});
// Imprimir Carnê
app.get('/api/imprimir-carne/:installmentId', async (req, res) => {
try {
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.' }); }
});
// ===================================================
// 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();