fix: movido delay de 5s para antes do envio e sincronizado server.js

This commit is contained in:
Sidney 2026-05-01 11:12:55 -03:00
parent 71f5a4159f
commit 08c89d4f41
3 changed files with 742 additions and 97 deletions

View File

@ -18,17 +18,20 @@ import { fileURLToPath } from 'url';
import multer from 'multer'; import multer from 'multer';
import sharp from 'sharp'; import sharp from 'sharp';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import cron from 'node-cron';
// === Novos módulos Self-Hosted (substituem Supabase) === // === Novos módulos Self-Hosted (substituem Supabase) ===
import { import {
getSchoolData, saveSchoolData, pool, getSchoolData, saveSchoolData, pool,
insertCobrancas, updateCobranca, deleteCobranca, insertCobrancas, updateCobranca, deleteCobranca,
getCobrancaByPaymentId, getCobrancasByOrQuery, getCobrancaByPaymentId, getCobrancasByOrQuery,
getCobrancasByAlunoId, getCobrancasAtrasadas, getCobrancasByAlunoId, getCobrancasAtrasadas, getCobrancasPendentes,
getCobrancasByInstallmentId, updateCobrancaLinkCarne, getCobrancasByInstallmentId, updateCobrancaLinkCarne,
updateCobrancaByField updateCobrancaByField,
initNotasTable, getNotasByAluno, upsertNota
} from './services/database.js'; } from './services/database.js';
import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage } from './services/storage.js'; import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage, getMinioStats, s3Client, getBucketObjects, deleteMinioObject } from './services/storage.js';
import { GetObjectCommand } from '@aws-sdk/client-s3';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@ -47,8 +50,31 @@ app.use(cors());
const cancelCache = new Set(); const cancelCache = new Set();
const sentCache = new Set(); const sentCache = new Set();
const lockCache = 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
const upload = multer({ storage: multer.memoryStorage() }); // ============================================================
// 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) // ROTA NOVA: Login Administrativo (JWT)
@ -91,6 +117,44 @@ app.post('/api/auth/login', async (req, res) => {
app.get('/api/school-data', async (req, res) => { app.get('/api/school-data', async (req, res) => {
try { try {
const data = await getSchoolData(); 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 }); res.json({ data });
} catch (error) { } catch (error) {
console.error('Erro ao buscar school_data:', error); console.error('Erro ao buscar school_data:', error);
@ -112,6 +176,25 @@ app.put('/api/school-data', async (req, res) => {
return res.status(409).json({ success: false, reason: 'newer_version' }); 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(); schoolData.lastUpdated = new Date().toISOString();
await saveSchoolData(schoolData); await saveSchoolData(schoolData);
res.json({ success: true }); res.json({ success: true });
@ -121,42 +204,162 @@ app.put('/api/school-data', async (req, res) => {
} }
}); });
// ============================================================ app.get('/api/system-stats', async (req, res) => {
// ROTA MÁGICA: Driblar Firewall e Migrar via HTTP let postgresStats = { dbSize: 'N/A', tableCount: '0' };
// ============================================================
app.post('/api/migracao-remota', async (req, res) => {
try { try {
const { senha, sql, jsonData } = req.body; const dbResult = await pool.query(`
if (senha !== 'magia2026') return res.status(401).send('Não autorizado'); 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
});
});
const client = await pool.connect(); // ============================================================
try { // Database Explorer
await client.query('BEGIN'); // ============================================================
// 1. Criar todas as tabelas! app.get('/api/database/tables', async (req, res) => {
if (sql) await client.query(sql); try {
const query = `
// 2. Injetar a mega tabela ponte SELECT
if (jsonData) { relname as table_name,
await client.query( pg_size_pretty(pg_total_relation_size(relid)) as total_size,
`INSERT INTO school_data (id, data, updated_at) VALUES (1, $1, NOW()) pg_total_relation_size(relid) as raw_size,
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data, updated_at = NOW()`, n_live_tup as row_count
[JSON.stringify(jsonData)] FROM pg_stat_user_tables
); ORDER BY raw_size DESC;
} `;
await client.query('COMMIT'); const result = await pool.query(query);
res.json({ success: true, message: 'Banco de dados criado e populado pela internet!' }); res.json({ tables: result.rows });
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
} catch (error) { } catch (error) {
console.error('Erro na migração HTTP:', error); console.error('Erro ao listar tabelas:', error);
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
app.get('/api/database/tables/:tableName/data', async (req, res) => {
try {
const { tableName } = req.params;
// Basic validation to prevent SQL injection on table name
if (!/^[a-zA-Z0-9_]+$/.test(tableName)) {
return res.status(400).json({ error: 'Nome de tabela inválido' });
}
const query = `SELECT * FROM "${tableName}" LIMIT 100;`;
const result = await pool.query(query);
res.json({ rows: result.rows, fields: result.fields.map(f => f.name) });
} catch (error) {
console.error(`Erro ao buscar dados da tabela ${req.params.tableName}:`, error);
res.status(500).json({ error: error.message });
}
});
// ============================================================
// MinIO Explorer
// ============================================================
app.get('/api/storage/buckets/:bucketName/objects', async (req, res) => {
try {
const { bucketName } = req.params;
const objects = await getBucketObjects(bucketName);
res.json({ objects });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.delete('/api/storage/buckets/:bucketName/objects', async (req, res) => {
try {
const { bucketName } = req.params;
const { key } = req.body;
if (!key) return res.status(400).json({ error: 'Key is required' });
await deleteMinioObject(bucketName, key);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ============================================================
// Rota para buscar submissões (acertos/erros) do aluno
// ============================================================
app.get('/api/student-submissions/:studentId', async (req, res) => {
try {
const { studentId } = req.params;
const { rows } = await pool.query(
'SELECT prova_id as "prova_id", acertos, erros FROM provas_submissoes WHERE TRIM(aluno_id) = TRIM($1)',
[String(studentId).trim()]
);
res.json({ submissions: rows });
} catch (err) {
console.error('Erro ao buscar submissões do aluno:', err);
res.status(500).json({ error: 'Erro interno' });
}
});
// ============================================================
// ROTAS DE NOTAS (NOVA TABELA)
// ============================================================
app.get('/api/notas/:alunoId', async (req, res) => {
try {
const { rows: dbNotas } = await pool.query(
'SELECT id, aluno_id as "aluno_id", disciplina_id as "disciplina_id", periodo_id as "periodo_id", prova_id as "prova_id", valor as "valor" FROM notas_boletim WHERE TRIM(aluno_id) = TRIM($1)',
[String(req.params.alunoId).trim()]
);
// Garantir cast numérico para evitar erro de .toFixed no frontend
const notas = dbNotas.map(n => ({ ...n, valor: Number(n.valor) }));
res.json({ notas });
} catch (err) {
console.error('Erro ao buscar notas do aluno:', err);
res.status(500).json({ error: 'Erro interno' });
}
});
app.post('/api/notas', async (req, res) => {
try {
const { notas } = req.body;
if (!Array.isArray(notas)) return res.status(400).json({ error: 'Formato inválido' });
for (const nota of notas) {
if (nota.valor !== null && nota.valor !== '' && !isNaN(Number(nota.valor))) {
await upsertNota({
aluno_id: String(nota.aluno_id),
disciplina_id: String(nota.disciplina_id),
periodo_id: String(nota.periodo_id),
prova_id: nota.prova_id ? String(nota.prova_id) : null,
valor: Number(nota.valor)
});
}
}
// Opcionalmente implementar delete para notas que o professor limpou (vazio)
res.json({ success: true });
} catch (err) {
console.error('Erro ao salvar notas manuais:', err);
res.status(500).json({ error: 'Erro interno' });
}
});
// ============================================================ // ============================================================
// Upload de Logo (MinIO em vez de Supabase Storage) // Upload de Logo (MinIO em vez de Supabase Storage)
// ============================================================ // ============================================================
@ -197,6 +400,42 @@ app.post('/api/upload/student-photo', upload.single('photo'), async (req, res) =
} }
}); });
// ============================================================
// 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 // Formatação de Data
// ============================================================ // ============================================================
@ -297,11 +536,7 @@ async function sendEvolutionMessage(asaasPaymentId, eventType, paymentPayload =
} }
} }
const fbGerado = 'Olá {nome}, sua cobrança referente a {descricao} no valor de R$ {valor} foi gerada. Vencimento: {vencimento}.'; const fbAVencer = 'Olá {nome}, lembramos que sua cobrança referente a {descricao} no valor de R$ {valor} vencerá em {vencimento}. Segue o PDF abaixo:';
const fbPago = 'Olá {nome}, confirmamos o pagamento de R$ {valor} referente a {descricao}. Muito obrigado!';
const fbAtrasado = 'Olá {nome}, o boleto referente a {descricao} de R$ {valor} venceu em {vencimento}. Segue o PDF da 2ª via atualizada abaixo:';
const fbCancelado = 'Olá {nome}, a cobrança referente a {descricao} foi cancelada.';
const fbAtualizado = 'Olá {nome}, o boleto de {descricao} foi atualizado. Segue a nova versão:';
let templateText = ''; let templateText = '';
if (eventType === 'PAYMENT_CREATED') templateText = templates?.boletoGerado || fbGerado; if (eventType === 'PAYMENT_CREATED') templateText = templates?.boletoGerado || fbGerado;
@ -309,6 +544,8 @@ async function sendEvolutionMessage(asaasPaymentId, eventType, paymentPayload =
else if (eventType === 'PAYMENT_OVERDUE') templateText = templates?.boletoVencido || fbAtrasado; else if (eventType === 'PAYMENT_OVERDUE') templateText = templates?.boletoVencido || fbAtrasado;
else if (eventType === 'PAYMENT_DELETED') templateText = templates?.cobrancaCancelada || fbCancelado; else if (eventType === 'PAYMENT_DELETED') templateText = templates?.cobrancaCancelada || fbCancelado;
else if (eventType === 'PAYMENT_UPDATED') templateText = templates?.cobrancaAtualizada || fbAtualizado; else if (eventType === 'PAYMENT_UPDATED') templateText = templates?.cobrancaAtualizada || fbAtualizado;
else if (eventType === 'PAYMENT_UPCOMING') templateText = templates?.boletoAVencer || fbAVencer;
if (!templateText) return; if (!templateText) return;
let msgFinal = templateText let msgFinal = templateText
@ -418,8 +655,9 @@ app.post('/api/webhook_asaas', async (req, res) => {
const statusMap = { 'PENDING': 'PENDENTE', 'OVERDUE': 'ATRASADO', 'RECEIVED': 'PAGO', 'CONFIRMED': 'PAGO', 'RECEIVED_IN_CASH': 'PAGO', 'REFUNDED': 'CANCELADO', 'DELETED': 'CANCELADO' }; const statusMap = { 'PENDING': 'PENDENTE', 'OVERDUE': 'ATRASADO', 'RECEIVED': 'PAGO', 'CONFIRMED': 'PAGO', 'RECEIVED_IN_CASH': 'PAGO', 'REFUNDED': 'CANCELADO', 'DELETED': 'CANCELADO' };
updateData = { valor: payload.payment.value, vencimento: payload.payment.dueDate, status: statusMap[payload.payment.status] || undefined }; updateData = { valor: payload.payment.value, vencimento: payload.payment.dueDate, status: statusMap[payload.payment.status] || undefined };
Object.keys(updateData).forEach(k => updateData[k] === undefined && delete updateData[k]); Object.keys(updateData).forEach(k => updateData[k] === undefined && delete updateData[k]);
if (payload.event === 'PAYMENT_OVERDUE') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_OVERDUE'); // Ocultado PAYMENT_OVERDUE aqui para ser enviado apenas pela rotina/cron (conforme regras)
else if (payload.event === 'PAYMENT_UPDATED') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_UPDATED'); // if (payload.event === 'PAYMENT_OVERDUE') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_OVERDUE');
if (payload.event === 'PAYMENT_UPDATED') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_UPDATED');
break; break;
case 'PAYMENT_DELETED': case 'PAYMENT_DELETED':
@ -451,6 +689,36 @@ app.post('/api/webhook_asaas', async (req, res) => {
} }
}); });
// 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');
res.json(result.rows);
} catch(e) {
res.status(500).json({error: e.message});
}
});
app.delete('/api/admin/cobrancas', async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids)) return res.status(400).end();
await pool.query('DELETE FROM alunos_cobrancas WHERE asaas_payment_id = ANY($1)', [ids]);
res.json({ success: true });
} catch(e) {
res.status(500).json({error: e.message});
}
});
app.delete('/api/admin/cobrancas/:id', async (req, res) => {
try {
await pool.query('DELETE FROM alunos_cobrancas WHERE asaas_payment_id = $1', [req.params.id]);
res.json({ success: true });
} catch(e) {
res.status(500).json({error: e.message});
}
});
// Webhook Evolution // Webhook Evolution
app.post('/api/webhooks/evolution', (req, res) => { app.post('/api/webhooks/evolution', (req, res) => {
try { try {
@ -694,27 +962,275 @@ app.get('/api/alunos/:id/carne', async (req, res) => {
// ============================================================ // ============================================================
// INICIALIZAÇÃO // INICIALIZAÇÃO
// ============================================================ // ============================================================
async function startServer() { // ============================================================
const distPath = path.join(__dirname, 'dist'); // LÓGICA REUTILIZÁVEL DE DISPARO DE COBRANÇAS
if (fs.existsSync(distPath)) { // ============================================================
app.use(express.static(distPath)); async function executarRotinaCobrancas(tipo = 'ambos') {
app.use((req, res, next) => req.path.startsWith('/api') ? next() : res.sendFile(path.join(distPath, 'index.html'))); const appData = await getSchoolData();
} else { const rules = appData?.messageTemplates?.automationRules || {};
const vite = await import('vite').then(m => m.createServer({ server: { middlewareMode: true }, appType: 'spa' })); const sendDaysBefore = parseInt(rules.sendDaysBefore) || 3;
app.use(vite.middlewares); const maxPreWarnings = parseInt(rules.maxPreWarnings) || 1;
const sendDaysAfter = parseInt(rules.sendDaysAfter) || 1;
const repeatEveryDays = parseInt(rules.repeatEveryDays) || 3;
let enviadasAtraso = 0;
let enviadasAviso = 0;
// 1. Processar Atrasados
if (tipo === 'atrasado' || tipo === 'ambos') {
const atrasados = await getCobrancasAtrasadas();
const hoje = new Date();
hoje.setHours(0,0,0,0);
for (const cob of atrasados) {
if (!cob.asaas_payment_id || !cob.vencimento) continue;
const vencimento = new Date(cob.vencimento);
vencimento.setHours(0,0,0,0);
const diffDiasAtraso = Math.floor((hoje.getTime() - vencimento.getTime()) / (1000 * 60 * 60 * 24));
if (diffDiasAtraso >= sendDaysAfter) {
const lastWarn = cob.last_overdue_warning_at ? new Date(cob.last_overdue_warning_at) : null;
if (lastWarn) lastWarn.setHours(0,0,0,0);
const diasDesdeUltimoAviso = lastWarn
? Math.floor((hoje.getTime() - lastWarn.getTime()) / (1000 * 60 * 60 * 24))
: null;
const jaEnviadoHoje = lastWarn && lastWarn.getTime() === hoje.getTime();
if (!jaEnviadoHoje && (diasDesdeUltimoAviso === null || diasDesdeUltimoAviso >= repeatEveryDays)) {
await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_OVERDUE');
const currentCount = parseInt(cob.overdue_warnings_count) || 0;
await pool.query(
'UPDATE alunos_cobrancas SET overdue_warnings_count = $1, last_overdue_warning_at = NOW() WHERE asaas_payment_id = $2',
[currentCount + 1, cob.asaas_payment_id]
);
enviadasAtraso++;
}
}
}
} }
// Disparo Manual de Inadimplência // 2. Processar A Vencer (Lembretes Preventivos)
if (tipo === 'preventivo' || tipo === 'ambos') {
const pendentes = await getCobrancasPendentes();
const hoje = new Date();
hoje.setHours(0,0,0,0);
for (const cob of pendentes) {
if (!cob.asaas_payment_id || !cob.vencimento) continue;
const vencimento = new Date(cob.vencimento);
vencimento.setHours(0,0,0,0);
const diffDias = Math.ceil((vencimento.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24));
if (diffDias > 0 && diffDias <= sendDaysBefore) {
const currentCount = parseInt(cob.pre_warnings_count) || 0;
if (currentCount < maxPreWarnings) {
const lastWarn = cob.last_pre_warning_at ? new Date(cob.last_pre_warning_at) : null;
const jaEnviadoHoje = lastWarn && lastWarn.toDateString() === hoje.toDateString();
if (!jaEnviadoHoje) {
await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_UPCOMING');
await pool.query(
'UPDATE alunos_cobrancas SET pre_warnings_count = $1, last_pre_warning_at = NOW() WHERE asaas_payment_id = $2',
[currentCount + 1, cob.asaas_payment_id]
);
enviadasAviso++;
}
}
}
}
}
return { enviadasAtraso, enviadasAviso };
}
// ============================================================
// AGENDADOR AUTOMÁTICO (node-cron) — Suporte a múltiplos tipos
// ============================================================
function agendarRotina(tipo, hora, minuto) {
const isPreventivo = tipo === 'preventivo';
const label = isPreventivo ? 'Preventivo' : 'Inadimplência';
// Cancela job anterior do mesmo tipo
if (isPreventivo && activeCronJob) {
activeCronJob.stop();
activeCronJob = null;
console.log(`[Cron:${label}] ⏹ Rotina anterior cancelada.`);
} else if (!isPreventivo && activeCronJobOverdue) {
activeCronJobOverdue.stop();
activeCronJobOverdue = null;
console.log(`[Cron:${label}] ⏹ Rotina anterior cancelada.`);
}
const h = parseInt(hora);
const m = parseInt(minuto);
if (isNaN(h) || isNaN(m) || h < 0 || h > 23 || m < 0 || m > 59) {
console.error(`[Cron:${label}] Horário inválido:`, hora, minuto);
return;
}
const cronTipo = isPreventivo ? 'preventivo' : 'atrasado';
const cronExpression = `${m} ${h} * * *`;
const job = cron.schedule(cronExpression, async () => {
console.log(`[Cron:${label}] ⏰ Rotina automática iniciada às ${new Date().toLocaleTimeString('pt-BR')}`);
try {
const resultado = await executarRotinaCobrancas(cronTipo);
const count = isPreventivo ? resultado.enviadasAviso : resultado.enviadasAtraso;
console.log(`[Cron:${label}] ✅ Concluído: ${count} mensagens processadas.`);
} catch (error) {
console.error(`[Cron:${label}] ❌ Erro na rotina automática:`, error.message);
}
}, { timezone: 'America/Sao_Paulo' });
if (isPreventivo) activeCronJob = job;
else activeCronJobOverdue = job;
console.log(`[Cron:${label}] ✅ Rotina agendada para ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')} (America/Sao_Paulo)`);
}
async function inicializarAgendamento() {
try {
// Inicialização DB para colunas de automação (garantir no boot)
await pool.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='pre_warnings_count') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN pre_warnings_count INTEGER DEFAULT 0;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='last_pre_warning_at') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN last_pre_warning_at TIMESTAMP WITH TIME ZONE;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='overdue_warnings_count') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN overdue_warnings_count INTEGER DEFAULT 0;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='last_overdue_warning_at') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN last_overdue_warning_at TIMESTAMP WITH TIME ZONE;
END IF;
END $$;
`).catch(err => console.error('[PostgreSQL] Erro boot automação:', err));
// Inicialização da Tabela de Notas e Migração Automática
await initNotasTable();
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.');
}
} catch (e) {
console.error('[Cron] Erro ao inicializar agendamento:', e.message);
}
}
async function startServer() {
// Disparo Manual de Inadimplência e Lembretes
app.post('/api/disparar_cobrancas', async (req, res) => { app.post('/api/disparar_cobrancas', async (req, res) => {
try { try {
const atrasados = await getCobrancasAtrasadas(); const tipo = req.query.tipo || 'ambos';
if (atrasados.length === 0) return res.status(200).json({ message: 'Nenhuma atrasada.' }); const resultado = await executarRotinaCobrancas(tipo);
let enviadas = 0;
for (const cob of atrasados) { let msg = '';
if (cob.asaas_payment_id) { await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_OVERDUE'); enviadas++; } 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.' });
}
});
// API para gerenciar o agendamento (suporte a preventivo e atrasado)
app.get('/api/cron/status', (req, res) => {
res.json({
preventive: !!activeCronJob,
overdue: !!activeCronJobOverdue
});
});
app.post('/api/cron/schedule', async (req, res) => {
try {
const { enabled, time, tipo } = req.body;
const isOverdue = tipo === 'atrasado';
const appData = await getSchoolData();
if (!appData.messageTemplates) appData.messageTemplates = {};
if (!appData.messageTemplates.automationRules) appData.messageTemplates.automationRules = {};
if (isOverdue) {
appData.messageTemplates.automationRules.autoScheduleOverdueEnabled = !!enabled;
appData.messageTemplates.automationRules.autoScheduleOverdueTime = time || '09:00';
} else {
appData.messageTemplates.automationRules.autoScheduleEnabled = !!enabled;
appData.messageTemplates.automationRules.autoScheduleTime = time || '09:00';
} }
return res.status(200).json({ message: `${enviadas} mensagens processadas.` });
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); } appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
if (enabled && time) {
const [h, m] = time.split(':');
agendarRotina(isOverdue ? 'atrasado' : 'preventivo', h, m);
} else {
if (isOverdue) {
if (activeCronJobOverdue) { activeCronJobOverdue.stop(); activeCronJobOverdue = null; }
} else {
if (activeCronJob) { activeCronJob.stop(); activeCronJob = null; }
}
}
res.json({
success: true,
preventive: !!activeCronJob,
overdue: !!activeCronJobOverdue
});
} catch (error) {
console.error('[Cron] Erro ao salvar agendamento:', error);
res.status(500).json({ error: 'Erro interno.' });
}
}); });
// Imprimir Carnê // Imprimir Carnê
@ -755,7 +1271,33 @@ async function startServer() {
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); } } catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
}); });
app.listen(PORT, '0.0.0.0', () => console.log(`🚀 EduManager Self-Hosted na porta ${PORT}`)); // ===================================================
// 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(); startServer();

View File

@ -16,6 +16,11 @@ import jwt from 'jsonwebtoken';
import pg from 'pg'; import pg from 'pg';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import multer from 'multer';
import { uploadAtestado, s3Client } from './services/storage.js';
import { GetObjectCommand } from '@aws-sdk/client-s3';
const upload = multer({ storage: multer.memoryStorage() });
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@ -37,6 +42,24 @@ app.use(cors());
app.use(express.json({ limit: '50mb' })); app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true })); app.use(express.urlencoded({ limit: '50mb', extended: true }));
// ============================================================
// 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];
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) {
res.status(404).send('Arquivo não encontrado');
}
});
// ===== Helper: Get school data (PostgreSQL) ===== // ===== Helper: Get school data (PostgreSQL) =====
async function getSchoolData() { async function getSchoolData() {
const { rows } = await pool.query( const { rows } = await pool.query(
@ -45,6 +68,21 @@ async function getSchoolData() {
return rows[0]?.data || {}; return rows[0]?.data || {};
} }
// ===== Helper: Normalizar URLs do MinIO para proxy relativo =====
function normalizeStorageUrl(url) {
if (!url || typeof url !== 'string') return url;
if (url.startsWith('/storage/')) return url;
const MINIO_PUBLIC_URL = process.env.MINIO_PUBLIC_URL || '';
if (MINIO_PUBLIC_URL && url.startsWith(MINIO_PUBLIC_URL)) {
return url.replace(MINIO_PUBLIC_URL, '/storage');
}
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
if (match && (url.includes('minio') || url.includes('storageedu') || url.includes(':9000'))) {
return `/storage/${match[1]}`;
}
return url;
}
// ===== Helper: Save school data (PostgreSQL) ===== // ===== Helper: Save school data (PostgreSQL) =====
async function saveSchoolData(data) { async function saveSchoolData(data) {
await pool.query( await pool.query(
@ -116,6 +154,9 @@ app.post('/api/portal/login', async (req, res) => {
? (schoolData.courses || []).find((c) => c.id === studentClass.courseId) || null ? (schoolData.courses || []).find((c) => c.id === studentClass.courseId) || null
: null; : null;
// Normalizar foto do aluno
if (student.photo) student.photo = normalizeStorageUrl(student.photo);
res.json({ res.json({
token, token,
user: tokenPayload, user: tokenPayload,
@ -135,7 +176,7 @@ app.get('/api/portal/escola', async (req, res) => {
const schoolData = await getSchoolData(); const schoolData = await getSchoolData();
res.json({ res.json({
name: schoolData.profile?.name || 'Escola', name: schoolData.profile?.name || 'Escola',
logo: schoolData.logo || null, logo: normalizeStorageUrl(schoolData.logo) || null,
profile: schoolData.profile || null, profile: schoolData.profile || null,
}); });
} catch (err) { } catch (err) {
@ -160,6 +201,9 @@ app.get('/api/portal/me', authMiddleware, async (req, res) => {
? (schoolData.courses || []).find((c) => c.id === studentClass.courseId) || null ? (schoolData.courses || []).find((c) => c.id === studentClass.courseId) || null
: null; : null;
// Normalizar foto
if (student.photo) student.photo = normalizeStorageUrl(student.photo);
res.json({ res.json({
student: { ...student, portalPassword: undefined }, student: { ...student, portalPassword: undefined },
class: studentClass, class: studentClass,
@ -201,14 +245,43 @@ app.get('/api/portal/notas', authMiddleware, async (req, res) => {
try { try {
const schoolData = await getSchoolData(); const schoolData = await getSchoolData();
const student = (schoolData.students || []).find(s => s.id === req.user.studentId); const student = (schoolData.students || []).find(s => s.id === req.user.studentId);
const grades = (schoolData.grades || []).filter((g) => g.studentId === req.user.studentId);
// Buscar notas direto da nova tabela
const { rows: dbGrades } = await pool.query(
'SELECT id, aluno_id as "studentId", disciplina_id as "subjectId", periodo_id as "period", prova_id as "examId", valor as "value" FROM notas_boletim WHERE aluno_id = $1',
[req.user.studentId]
);
// Converter valor numérico
const grades = dbGrades.map(g => ({ ...g, value: Number(g.value) }));
const subjects = schoolData.subjects || []; const subjects = schoolData.subjects || [];
const courseSubjects = subjects.filter(s => !s.classId || s.classId === student?.classId); const courseSubjects = subjects.filter(s => !s.classId || s.classId === student?.classId);
// Buscar submissões para pegar acertos e erros
const { rows: submissions } = await pool.query(
'SELECT prova_id, acertos, erros FROM provas_submissoes WHERE aluno_id = $1',
[req.user.studentId]
);
const enrichedGrades = grades.map((g) => { const enrichedGrades = grades.map((g) => {
const subject = subjects.find((s) => s.id === g.subjectId); const subject = subjects.find((s) => String(s.id).trim() === String(g.subjectId).trim());
return { ...g, subjectName: subject?.name || 'Disciplina desconhecida' }; const exam = g.examId ? (schoolData.exams || []).find(e => String(e.id).trim() === String(g.examId).trim()) : null;
const periodObj = (schoolData.periods || []).find(p => String(p.id).trim() === String(g.period).trim());
const submission = g.examId ? submissions.find(s => String(s.prova_id) === String(g.examId)) : null;
return {
...g,
subjectName: subject?.name || 'Disciplina desconhecida',
examTitle: exam?.title,
evaluationType: exam?.evaluationType || 'exam',
maxScore: exam?.maxScore,
periodName: periodObj ? periodObj.name : g.period,
correctCount: submission?.acertos,
wrongCount: submission?.erros
};
}); });
const periods = [...new Set(grades.map((g) => g.period))]; const periods = [...new Set(enrichedGrades.map((g) => g.periodName))];
if (periods.length === 0) periods.push('1º Bimestre', '2º Bimestre', '3º Bimestre', '4º Bimestre'); if (periods.length === 0) periods.push('1º Bimestre', '2º Bimestre', '3º Bimestre', '4º Bimestre');
periods.sort(); periods.sort();
res.json({ grades: enrichedGrades, periods, allSubjects: courseSubjects }); res.json({ grades: enrichedGrades, periods, allSubjects: courseSubjects });
@ -229,11 +302,16 @@ app.get('/api/portal/frequencia', authMiddleware, async (req, res) => {
}); });
// POST /api/portal/frequencia/justificar // POST /api/portal/frequencia/justificar
app.post('/api/portal/frequencia/justificar', authMiddleware, async (req, res) => { app.post('/api/portal/frequencia/justificar', authMiddleware, upload.single('arquivo'), async (req, res) => {
try { try {
const { date, justification } = req.body; const { date, motivo } = req.body;
if (!date) return res.status(400).json({ error: 'A data da aula é obrigatória' }); if (!date) return res.status(400).json({ error: 'A data da aula é obrigatória' });
if (!justification || justification.trim() === '') return res.status(400).json({ error: 'A justificativa é obrigatória' }); if (!motivo || motivo.trim() === '') return res.status(400).json({ error: 'A justificativa (motivo) é obrigatória' });
let publicUrl = null;
if (req.file) {
publicUrl = await uploadAtestado(req.file.buffer, req.file.mimetype);
}
const schoolData = await getSchoolData(); const schoolData = await getSchoolData();
const attendance = schoolData.attendance || []; const attendance = schoolData.attendance || [];
@ -241,33 +319,40 @@ app.post('/api/portal/frequencia/justificar', authMiddleware, async (req, res) =
const student = (schoolData.students || []).find(s => s.id === req.user.studentId); const student = (schoolData.students || []).find(s => s.id === req.user.studentId);
const fullDateStr = date; const fullDateStr = date;
const justificationPayload = JSON.stringify({ motivo: motivo.trim(), arquivo: publicUrl });
let recordIndex = attendance.findIndex(a => a.studentId === req.user.studentId && a.date === fullDateStr); let recordIndex = attendance.findIndex(a => a.studentId === req.user.studentId && a.date === fullDateStr);
if (recordIndex !== -1) { if (recordIndex !== -1) {
const existing = attendance[recordIndex]; const existing = attendance[recordIndex];
if (existing.type === 'presence') return res.status(400).json({ error: 'Não é possível justificar uma presença' }); if (existing.type === 'presence') return res.status(400).json({ error: 'Não é possível justificar uma presença' });
attendance[recordIndex] = { ...existing, justification: justification.trim() }; attendance[recordIndex] = { ...existing, justification: justificationPayload };
} else { } else {
const newRecord = { const newRecord = {
id: `att-just-${Date.now()}`, studentId: req.user.studentId, classId: student?.classId || '', id: `att-just-${Date.now()}`, studentId: req.user.studentId, classId: student?.classId || '',
date: fullDateStr, verified: false, type: 'absence', justification: justification.trim(), date: fullDateStr, verified: false, type: 'absence', justification: justificationPayload,
}; };
attendance.push(newRecord); attendance.push(newRecord);
recordIndex = attendance.length - 1; recordIndex = attendance.length - 1;
} }
let attachment = null;
try { const parsed = JSON.parse(justification); attachment = parsed.arquivo_base64 || null; } catch (e) { }
notifications.push({ notifications.push({
id: `notif-${Date.now()}`, studentId: 'admin', id: `notif-${Date.now()}`,
studentId: 'admin',
fromStudentId: req.user.studentId, // Identificador para navegação no Manager
title: 'Nova Justificativa de Falta', title: 'Nova Justificativa de Falta',
message: `${student?.name || 'Aluno'} enviou uma justificativa para a aula de ${date}.`, message: JSON.stringify({
attachment, read: false, createdAt: new Date().toISOString(), text: `${student?.name || 'Aluno'} enviou uma justificativa para a aula de ${date}.`,
motivo: motivo.trim()
}),
attachment: publicUrl,
read: false,
createdAt: new Date().toISOString(),
}); });
schoolData.attendance = attendance; schoolData.attendance = attendance;
schoolData.notifications = notifications; schoolData.notifications = notifications;
schoolData.lastUpdated = new Date().toISOString();
await saveSchoolData(schoolData); await saveSchoolData(schoolData);
res.json({ message: 'Justificativa enviada com sucesso', record: attendance[recordIndex] }); res.json({ message: 'Justificativa enviada com sucesso', record: attendance[recordIndex] });
@ -358,6 +443,7 @@ app.put('/api/portal/notificacoes/ler/:id', authMiddleware, async (req, res) =>
if (idx === -1) return res.status(404).json({ error: 'Notificação não encontrada' }); if (idx === -1) return res.status(404).json({ error: 'Notificação não encontrada' });
notifications[idx] = { ...notifications[idx], read: true }; notifications[idx] = { ...notifications[idx], read: true };
schoolData.notifications = notifications; schoolData.notifications = notifications;
schoolData.lastUpdated = new Date().toISOString();
await saveSchoolData(schoolData); await saveSchoolData(schoolData);
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
@ -373,6 +459,7 @@ app.delete('/api/portal/notificacoes/:id', authMiddleware, async (req, res) => {
schoolData.notifications = (schoolData.notifications || []).filter( schoolData.notifications = (schoolData.notifications || []).filter(
n => !(n.id === id && n.studentId === req.user.studentId) n => !(n.id === id && n.studentId === req.user.studentId)
); );
schoolData.lastUpdated = new Date().toISOString();
await saveSchoolData(schoolData); await saveSchoolData(schoolData);
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
@ -398,6 +485,7 @@ app.put('/api/portal/alterar-senha', authMiddleware, async (req, res) => {
students[studentIndex] = { ...student, portalPassword: newPassword }; students[studentIndex] = { ...student, portalPassword: newPassword };
schoolData.students = students; schoolData.students = students;
schoolData.lastUpdated = new Date().toISOString();
await saveSchoolData(schoolData); await saveSchoolData(schoolData);
res.json({ message: 'Senha alterada com sucesso' }); res.json({ message: 'Senha alterada com sucesso' });
@ -420,7 +508,12 @@ app.get('/api/portal/avaliacoes', authMiddleware, async (req, res) => {
.filter(e => e.status === 'published' && e.classId === student.classId) .filter(e => e.status === 'published' && e.classId === student.classId)
.map(e => ({ .map(e => ({
...e, ...e,
questions: e.questions.map(q => ({ id: q.id, text: q.text, options: q.options })) questions: e.questions.map(q => ({
id: q.id,
text: q.text,
options: q.options,
imageUrl: normalizeStorageUrl(q.imageUrl)
}))
})); }));
const { rows: submissions } = await pool.query( const { rows: submissions } = await pool.query(
@ -429,14 +522,15 @@ app.get('/api/portal/avaliacoes', authMiddleware, async (req, res) => {
); );
// Mapear nomes de colunas do banco para o formato esperado pelo frontend // Mapear nomes de colunas do banco para o formato esperado pelo frontend
// IMPORTANTE: NUMERIC(5,2) retorna como string do pg, precisa de Number()
const mappedSubmissions = (submissions || []).map(s => ({ const mappedSubmissions = (submissions || []).map(s => ({
...s, ...s,
exam_id: s.prova_id || s.exam_id, exam_id: s.prova_id || s.exam_id,
total_questions: s.total_questoes || s.total_questions, total_questions: s.total_questoes || s.total_questions,
correct_count: s.acertos || s.correct_count, correct_count: s.acertos || s.correct_count,
wrong_count: s.erros || s.wrong_count, wrong_count: s.erros || s.wrong_count,
percentage: s.percentual || s.percentage, percentage: Number(s.percentual || s.percentage || 0),
final_score: s.nota_final || s.final_score, final_score: Number(s.nota_final || s.final_score || 0),
answers_json: s.respostas || s.answers_json, answers_json: s.respostas || s.answers_json,
})); }));
@ -452,17 +546,27 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
const { examId, answers } = req.body; const { examId, answers } = req.body;
if (!examId || !answers) return res.status(400).json({ error: 'Dados obrigatórios' }); if (!examId || !answers) return res.status(400).json({ error: 'Dados obrigatórios' });
// Verificar se já submeteu
const { rows: existing } = await pool.query(
'SELECT id FROM provas_submissoes WHERE aluno_id = $1 AND prova_id = $2 LIMIT 1',
[req.user.studentId, examId]
);
if (existing.length > 0) return res.status(409).json({ error: 'Você já realizou esta prova.' });
const schoolData = await getSchoolData(); const schoolData = await getSchoolData();
const exam = (schoolData.exams || []).find(e => e.id === examId); const exam = (schoolData.exams || []).find(e => e.id === examId);
if (!exam) return res.status(404).json({ error: 'Prova não encontrada.' }); if (!exam) return res.status(404).json({ error: 'Prova não encontrada.' });
// Verificar se já submeteu
const { rows: existing } = await pool.query(
'SELECT * FROM provas_submissoes WHERE aluno_id = $1 AND prova_id = $2 LIMIT 1',
[req.user.studentId, examId]
);
if (existing.length > 0) {
if (!exam.allowRetake) {
return res.status(409).json({ error: 'Você já realizou esta avaliação e ela não permite refação.' });
}
// Se permite refazer, deleta a anterior
await pool.query(
'DELETE FROM provas_submissoes WHERE aluno_id = $1 AND prova_id = $2',
[req.user.studentId, examId]
);
}
const totalQuestions = exam.questions.length; const totalQuestions = exam.questions.length;
let correctCount = 0; let correctCount = 0;
for (const q of exam.questions) { for (const q of exam.questions) {
@ -471,7 +575,8 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
const wrongCount = totalQuestions - correctCount; const wrongCount = totalQuestions - correctCount;
const percentage = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * 100).toFixed(2)) : 0; const percentage = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * 100).toFixed(2)) : 0;
const finalScore = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * 10).toFixed(2)) : 0; const maxScore = exam.maxScore != null ? Number(exam.maxScore) : 10;
const finalScore = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * maxScore).toFixed(2)) : 0;
// Salvar no PostgreSQL // Salvar no PostgreSQL
await pool.query( await pool.query(
@ -480,17 +585,15 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
[req.user.studentId, examId, totalQuestions, correctCount, wrongCount, percentage, finalScore, JSON.stringify(answers), new Date().toISOString()] [req.user.studentId, examId, totalQuestions, correctCount, wrongCount, percentage, finalScore, JSON.stringify(answers), new Date().toISOString()]
); );
// Integrar com grades no school_data // Integrar com notas_boletim (Nova Tabela) em vez de school_data
if (exam.subjectId && exam.periodId) { if (exam.subjectId && exam.periodId) {
const grades = schoolData.grades || []; await pool.query(
const existingGradeIndex = grades.findIndex(g => g.studentId === req.user.studentId && g.subjectId === exam.subjectId && g.period === exam.periodId); `INSERT INTO notas_boletim (aluno_id, disciplina_id, periodo_id, prova_id, valor, updated_at)
if (existingGradeIndex >= 0) { VALUES ($1, $2, $3, $4, $5, NOW())
grades[existingGradeIndex].value = finalScore; ON CONFLICT (aluno_id, disciplina_id, periodo_id, prova_id)
} else { DO UPDATE SET valor = EXCLUDED.valor, updated_at = NOW()`,
grades.push({ id: `grade-${Date.now()}-${Math.random().toString(36).substring(7)}`, studentId: req.user.studentId, subjectId: exam.subjectId, period: exam.periodId, value: finalScore }); [req.user.studentId, exam.subjectId, exam.periodId, examId, finalScore]
} );
schoolData.grades = grades;
await saveSchoolData(schoolData);
} }
res.json({ success: true, result: { total_questions: totalQuestions, correct_count: correctCount, wrong_count: wrongCount, percentage, final_score: finalScore } }); res.json({ success: true, result: { total_questions: totalQuestions, correct_count: correctCount, wrong_count: wrongCount, percentage, final_score: finalScore } });

View File

@ -131,6 +131,9 @@ export default function Avaliacoes() {
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
try { try {
// Artificial delay of 5 seconds to let the student read the message
await new Promise(resolve => setTimeout(resolve, 5000));
const res = await fetch('/api/portal/avaliacoes/submeter', { const res = await fetch('/api/portal/avaliacoes/submeter', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -141,9 +144,6 @@ export default function Avaliacoes() {
}); });
const data = await res.json(); const data = await res.json();
// Artificial delay of 5 seconds to let the student read the message
await new Promise(resolve => setTimeout(resolve, 5000));
if (data.success) { if (data.success) {
// Show Success Modal // Show Success Modal