2547 lines
102 KiB
JavaScript
2547 lines
102 KiB
JavaScript
/**
|
||
* ============================================================
|
||
* 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,
|
||
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, deleteContrato,
|
||
getAulasByTurma, getAllAulas, insertAulas, deleteAulas,
|
||
getProvas, getQuestoesDaProva, insertProva, updateProva, deleteProva, syncQuestoesProva
|
||
} 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' });
|
||
}
|
||
});
|
||
|
||
// ============================================================
|
||
// 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/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 {
|
||
await insertAluno(req.body);
|
||
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 {
|
||
await updateAluno(req.params.id, req.body);
|
||
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 {
|
||
await deleteAluno(req.params.id);
|
||
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); 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); 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); 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); 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); 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);
|
||
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);
|
||
res.json({ success: true });
|
||
} catch (error) {
|
||
console.error('Erro ao deletar aulas em lote:', 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 {
|
||
await insertProva(req.body);
|
||
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 {
|
||
await updateProva(req.params.id, req.body);
|
||
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 {
|
||
await deleteProva(req.params.id);
|
||
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' });
|
||
}
|
||
});
|
||
|
||
// ============================================================
|
||
// 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);
|
||
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);
|
||
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);
|
||
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 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]
|
||
};
|
||
|
||
// 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 ${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) 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 {
|
||
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 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) => 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 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 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 = 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.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();
|