/** * ============================================================ * PORTAL DO ALUNO — SERVER SELF-HOSTED * ============================================================ * SUBSTITUIÇÃO CIRÚRGICA: * - @supabase/supabase-js → pg (PostgreSQL direto) * * TODAS AS ROTAS mantêm a mesma assinatura e resposta. * O frontend React NÃO percebe a diferença. * ============================================================ */ import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import jwt from 'jsonwebtoken'; import pg from 'pg'; // Registrar parser global para tipo NUMERIC (OID 1700) para retornar como Number pg.types.setTypeParser(1700, (val) => val === null ? null : parseFloat(val)); import path from 'path'; 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 __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 3001; const JWT_SECRET = process.env.JWT_SECRET || 'EduManager-JWT-Secret-2026!'; // === PostgreSQL (substitui Supabase) === const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://edumanager:EduManager2026!Seguro@postgres:5432/edumanager'; const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 10, idleTimeoutMillis: 30000, }); // Middleware app.use(cors()); app.use(express.json({ limit: '50mb' })); 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: 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; } // ===== Auth Middleware ===== function authMiddleware(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'Token não fornecido' }); } const token = authHeader.split(' ')[1]; try { const decoded = jwt.verify(token, JWT_SECRET); req.user = decoded; next(); } catch { return res.status(401).json({ error: 'Token inválido ou expirado' }); } } // =================================================== // PUBLIC ROUTES // =================================================== // POST /api/portal/login app.post('/api/portal/login', async (req, res) => { try { const { enrollmentNumber, password } = req.body; if (!enrollmentNumber || !password) { return res.status(400).json({ error: 'Matrícula e senha são obrigatórios' }); } const { rows: dbStudents } = await pool.query( 'SELECT * FROM alunos WHERE numero_matricula ILIKE $1', [enrollmentNumber] ); if (dbStudents.length === 0) { return res.status(401).json({ error: 'Matrícula não encontrada' }); } const s = dbStudents[0]; const student = { id: s.id, enrollmentNumber: s.numero_matricula, name: s.nome, status: s.status, portalPassword: s.senha_portal, cpf: s.cpf, rg: s.rg, birthDate: s.data_nascimento, phone: s.telefone, email: s.email, addressStreet: s.rua, addressNumber: s.numero, addressNeighborhood: s.bairro, addressCity: s.cidade, addressState: s.estado, addressZip: s.cep, guardianName: s.nome_responsavel, guardianCpf: s.cpf_responsavel, guardianPhone: s.telefone_responsavel, classId: s.turma_id, photo: normalizeStorageUrl(s.foto_url) }; const expectedPassword = student.portalPassword || (student.cpf ? student.cpf.replace(/\D/g, '').substring(0, 6) : ''); if (password !== expectedPassword) { return res.status(401).json({ error: 'Senha incorreta' }); } if (student.status !== 'active') { return res.status(403).json({ error: 'Sua matrícula está inativa. Entre em contato com a secretaria.' }); } const tokenPayload = { studentId: student.id, enrollmentNumber: student.enrollmentNumber, name: student.name, }; const token = jwt.sign(tokenPayload, JWT_SECRET, { expiresIn: '7d' }); // Buscar Turma e Curso no PostgreSQL let studentClass = null; let course = null; if (student.classId) { const { rows: tRows } = await pool.query('SELECT * FROM turmas WHERE id = $1', [student.classId]); if (tRows.length > 0) { studentClass = { id: tRows[0].id, name: tRows[0].nome, courseId: tRows[0].curso_id }; if (studentClass.courseId) { const { rows: cRows } = await pool.query('SELECT * FROM cursos WHERE id = $1', [studentClass.courseId]); if (cRows.length > 0) course = { id: cRows[0].id, name: cRows[0].nome }; } } } res.json({ token, user: tokenPayload, student: { ...student, portalPassword: undefined }, class: studentClass, course, }); } catch (err) { console.error('Login error:', err); res.status(500).json({ error: 'Erro interno do servidor' }); } }); // GET /api/portal/escola app.get('/api/portal/escola', async (req, res) => { try { const { rows } = await pool.query('SELECT * FROM configuracoes ORDER BY tipo DESC, nome ASC LIMIT 1'); if (rows.length > 0) { const r = rows[0]; return res.json({ name: r.nome || 'Escola', logo: normalizeStorageUrl(r.logo) || null, profile: { id: r.id, name: r.nome, address: r.endereco, city: r.cidade, state: r.estado, zip: r.cep, cnpj: r.cnpj, phone: r.telefone, email: r.email, type: r.tipo } }); } res.json({ name: 'Escola', logo: null, profile: null, }); } catch (err) { console.error('Escola error:', err); res.status(500).json({ error: 'Erro ao buscar dados da escola' }); } }); // =================================================== // PROTECTED ROUTES // =================================================== // GET /api/portal/me app.get('/api/portal/me', authMiddleware, async (req, res) => { try { const { rows: dbStudents } = await pool.query( 'SELECT * FROM alunos WHERE id = $1', [req.user.studentId] ); if (dbStudents.length === 0) { return res.status(404).json({ error: 'Aluno não encontrado' }); } const s = dbStudents[0]; const student = { id: s.id, enrollmentNumber: s.numero_matricula, name: s.nome, status: s.status, portalPassword: s.senha_portal, cpf: s.cpf, rg: s.rg, birthDate: s.data_nascimento, phone: s.telefone, email: s.email, addressStreet: s.rua, addressNumber: s.numero, addressNeighborhood: s.bairro, addressCity: s.cidade, addressState: s.estado, addressZip: s.cep, guardianName: s.nome_responsavel, guardianCpf: s.cpf_responsavel, guardianPhone: s.telefone_responsavel, classId: s.turma_id, photo: normalizeStorageUrl(s.foto_url) }; let studentClass = null; let course = null; if (student.classId) { const { rows: tRows } = await pool.query('SELECT * FROM turmas WHERE id = $1', [student.classId]); if (tRows.length > 0) { studentClass = { id: tRows[0].id, name: tRows[0].nome, courseId: tRows[0].curso_id }; if (studentClass.courseId) { const { rows: cRows } = await pool.query('SELECT * FROM cursos WHERE id = $1', [studentClass.courseId]); if (cRows.length > 0) course = { id: cRows[0].id, name: cRows[0].nome }; } } } res.json({ student: { ...student, portalPassword: undefined }, class: studentClass, course, }); } catch (err) { console.error('Me error:', err); res.status(500).json({ error: 'Erro interno' }); } }); // GET /api/portal/financeiro (PostgreSQL como fonte primária — SQL-First) app.get('/api/portal/financeiro', authMiddleware, async (req, res) => { try { const statusMap = { 'pago': 'paid', 'paid': 'paid', 'received': 'paid', 'confirmed': 'paid', 'received_in_cash': 'paid', 'atrasado': 'overdue', 'overdue': 'overdue', 'vencido': 'overdue', 'pendente': 'pending', 'pending': 'pending', 'cancelado': 'cancelled', 'cancelled': 'cancelled', 'refunded': 'cancelled', 'deleted': 'cancelled' }; // 1. FONTE PRIMÁRIA: PostgreSQL (alunos_cobrancas) — contém TODOS os pagamentos criados let dbRows = []; try { const { rows } = await pool.query( `SELECT *, TO_CHAR(vencimento, 'YYYY-MM-DD') as vencimento, TO_CHAR(data_pagamento, 'YYYY-MM-DD') as data_pagamento FROM alunos_cobrancas WHERE aluno_id = $1 ORDER BY alunos_cobrancas.vencimento ASC`, [req.user.studentId] ); dbRows = rows || []; } catch (dbErr) { console.error('Financeiro: erro ao buscar do PostgreSQL -', dbErr.message); return res.status(500).json({ error: 'Erro no SQL: ' + dbErr.message, studentId: req.user.studentId }); } // 2. CONSTRUIR LISTA FINAL: Apenas registros do SQL (fonte única da verdade) const finalPayments = []; for (const db of dbRows) { const asaasId = db.asaas_payment_id; const localId = db.local_id; const dbStatus = (db.status || '').toLowerCase().trim(); const normalizedStatus = statusMap[dbStatus] || 'pending'; // Parcela: SQL tem prioridade, depois inferência por grupo let installmentNumber = db.installment_number || null; let totalInstallments = db.total_installments || null; if (!installmentNumber && db.asaas_installment_id) { const siblings = dbRows.filter(r => r.asaas_installment_id === db.asaas_installment_id); if (siblings.length > 1) { totalInstallments = siblings.length; installmentNumber = siblings.indexOf(db) + 1; } } const discount = Number(db.discount) || 0; const isPaid = ['paid', 'pago', 'received', 'confirmed'].includes(normalizedStatus); const valorPagoNoSQL = Number(db.valor_pago || 0); let amountBruto = Math.max( Number(db.amount_original || 0), Number(db.valor || 0) ); if (isPaid && discount > 0 && amountBruto > 0) { if (valorPagoNoSQL > 0 && amountBruto <= valorPagoNoSQL) { amountBruto = valorPagoNoSQL + discount; } } finalPayments.push({ id: localId || asaasId, studentId: req.user.studentId, asaasPaymentId: asaasId || null, asaasPaymentUrl: db.asaas_payment_url || null, amount: amountBruto, discount: discount, valor_pago: valorPagoNoSQL > 0 ? valorPagoNoSQL : (isPaid ? (amountBruto - discount) : 0), dueDate: db.vencimento, status: normalizedStatus, paidDate: db.data_pagamento || null, type: db.type || 'monthly', description: db.description || null, installmentNumber, totalInstallments, link_boleto: db.link_boleto || null, transactionReceiptUrl: db.transaction_receipt_url || null, }); } res.json({ payments: finalPayments, debugStudentId: req.user.studentId, dbRowsCount: dbRows.length }); } catch (err) { console.error('Financeiro error:', err); res.status(500).json({ error: 'Erro interno' }); } }); // GET /api/portal/boletos (PostgreSQL direto) app.get('/api/portal/boletos', authMiddleware, async (req, res) => { try { const { rows } = await pool.query( `SELECT *, TO_CHAR(vencimento, 'YYYY-MM-DD') as vencimento, TO_CHAR(data_pagamento, 'YYYY-MM-DD') as data_pagamento FROM alunos_cobrancas WHERE aluno_id = $1 ORDER BY alunos_cobrancas.vencimento ASC`, [req.user.studentId] ); // [Bugfix Crítico]: Recuperar valor bruto original e valor efetivamente pago const boletos = (rows || []).map(b => { const valorOriginal = Number(b.amount_original || b.valor || 0); const discount = Number(b.discount || 0); const valorPago = Number(b.valor_pago || 0); const status = (b.status || '').toLowerCase(); const isPaid = ['paid', 'pago', 'received', 'confirmed', 'recebido'].includes(status); // O valor principal exibido deve ser sempre o BRUTO original let valorExibido = valorOriginal; // Se por algum motivo o valorOriginal ainda for o líquido, tentamos recompor if (valorOriginal > 0 && isPaid && valorPago > 0 && valorOriginal === valorPago && discount > 0) { valorExibido = valorOriginal + discount; } return { ...b, valor: valorExibido, valor_pago: valorPago }; }); res.json({ boletos }); } catch (err) { console.error('Boletos error:', err); res.json({ boletos: [] }); } }); // GET /api/portal/notas app.get('/api/portal/notas', authMiddleware, async (req, res) => { try { const { rows: dbStudents } = await pool.query('SELECT turma_id FROM alunos WHERE id = $1', [req.user.studentId]); const classId = dbStudents.length > 0 ? dbStudents[0].turma_id : null; // 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) })); // Buscar disciplinas let subjects = []; if (classId) { const { rows: subRows } = await pool.query( 'SELECT id, nome as name, turma_id as "classId" FROM disciplinas WHERE turma_id IS NULL OR turma_id = $1 ORDER BY created_at ASC', [classId] ); subjects = subRows || []; } else { const { rows: subRows } = await pool.query( 'SELECT id, nome as name, turma_id as "classId" FROM disciplinas WHERE turma_id IS NULL ORDER BY created_at ASC' ); subjects = subRows || []; } const courseSubjects = subjects; // Buscar periodos const { rows: dbPeriods } = await pool.query('SELECT id, nome as name FROM periodos ORDER BY created_at ASC'); const periodsList = dbPeriods || []; // Buscar provas const { rows: dbExams } = await pool.query( 'SELECT id, titulo as title, evaluation_type as "evaluationType", max_score as "maxScore" FROM provas WHERE turma_id = $1', [classId] ); const examsList = dbExams || []; // 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 subject = subjects.find((s) => String(s.id).trim() === String(g.subjectId).trim()); const exam = g.examId ? examsList.find(e => String(e.id).trim() === String(g.examId).trim()) : null; const periodObj = periodsList.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 !== null && exam?.maxScore !== undefined ? Number(exam?.maxScore) : 10, periodName: periodObj ? periodObj.name : g.period, correctCount: submission?.acertos, wrongCount: submission?.erros }; }); const periods = [...new Set(enrichedGrades.map((g) => g.periodName))]; if (periods.length === 0) periods.push('1º Bimestre', '2º Bimestre', '3º Bimestre', '4º Bimestre'); periods.sort(); res.json({ grades: enrichedGrades, periods, allSubjects: courseSubjects }); } catch (err) { console.error('Notas error:', err); res.status(500).json({ error: 'Erro interno' }); } }); // GET /api/portal/frequencia (SQL-First — Leitura direta do PostgreSQL) app.get('/api/portal/frequencia', authMiddleware, async (req, res) => { try { const { rows } = await pool.query(` SELECT *, TO_CHAR(data, 'YYYY-MM-DD"T"HH24:MI:SS') as formatted_data FROM frequencias WHERE aluno_id = $1 ORDER BY created_at DESC `, [req.user.studentId]); const attendance = rows.map(r => ({ id: r.id, studentId: r.aluno_id, classId: r.turma_id, lessonId: r.aula_id, date: r.formatted_data || r.data, photo: r.foto_url || r.foto, verified: r.verificado, type: r.tipo, justification: r.justificativa, justificationAccepted: r.justificativa_aceita, createdAt: r.created_at })); res.json({ attendance }); } catch (err) { console.error('Frequencia error:', err); res.status(500).json({ error: 'Erro interno' }); } }); // POST /api/portal/frequencia/justificar app.post('/api/portal/frequencia/justificar', authMiddleware, upload.single('arquivo'), async (req, res) => { try { const { date, motivo } = req.body; if (!date) return res.status(400).json({ error: 'A data da aula é 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 { rows: dbStudents } = await pool.query('SELECT nome, turma_id FROM alunos WHERE id = $1', [req.user.studentId]); if (dbStudents.length === 0) return res.status(404).json({ error: 'Aluno não encontrado' }); const student = dbStudents[0]; const studentName = student.nome || 'Aluno'; const studentClassId = student.turma_id || ''; const fullDateStr = date; const justificationPayload = JSON.stringify({ motivo: motivo.trim(), arquivo: publicUrl }); const submittedAt = new Date().toISOString(); // Buscar registro de frequência existente no PostgreSQL const { rows: dbAttendance } = await pool.query( `SELECT * FROM frequencias WHERE aluno_id = $1 AND TO_CHAR(data, 'YYYY-MM-DD"T"HH24:MI:SS') = $2`, [req.user.studentId, fullDateStr] ); let recordId; if (dbAttendance.length > 0) { const existing = dbAttendance[0]; if (existing.tipo === 'presence') return res.status(400).json({ error: 'Não é possível justificar uma presença' }); // Regra de segurança: Cada aula só pode ter atestado enviado UMA única vez if (existing.justificativa) { return res.status(400).json({ error: 'Esta aula já possui um atestado/justificativa enviado.' }); } recordId = existing.id; await pool.query( `UPDATE frequencias SET justificativa = $1, justificativa_aceita = FALSE, created_at = $2 WHERE id = $3`, [justificationPayload, submittedAt, recordId] ); } else { recordId = `att-just-${Date.now()}`; await pool.query( `INSERT INTO frequencias (id, aluno_id, turma_id, data, verificado, tipo, justificativa, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [recordId, req.user.studentId, studentClassId, fullDateStr, false, 'absence', justificationPayload, submittedAt] ); } // Inserir notificação para o ADMIN na tabela SQL try { await pool.query( `INSERT INTO notificacoes (aluno_id, titulo, mensagem, anexo, lida, created_at) VALUES ($1, $2, $3, $4, $5, NOW())`, [ 'admin', 'Nova Justificativa de Falta', JSON.stringify({ text: `${studentName} enviou uma justificativa para a aula de ${date}.`, motivo: motivo.trim(), fromStudentId: req.user.studentId }), publicUrl, false ] ); } catch (notifErr) { console.error('[Portal:Justificação] Erro ao salvar notificação SQL:', notifErr.message); } res.json({ message: 'Justificativa enviada com sucesso', record: { id: recordId, studentId: req.user.studentId, classId: studentClassId, date: fullDateStr, verified: false, type: 'absence', justification: justificationPayload, submittedAt } }); } catch (err) { console.error('Justificativa error:', err); res.status(500).json({ error: 'Erro interno ao salvar justificativa' }); } }); // GET /api/portal/contratos (SQL-First) app.get('/api/portal/contratos', authMiddleware, async (req, res) => { try { const { rows } = await pool.query( `SELECT id, aluno_id as "studentId", titulo as title, conteudo as content, TO_CHAR(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') as "createdAt" FROM contratos WHERE aluno_id = $1 ORDER BY created_at DESC`, [req.user.studentId] ); res.json({ contracts: rows }); } catch (err) { console.error('Erro contratos portal:', err); res.status(500).json({ error: 'Erro interno' }); } }); // GET /api/portal/certificados app.get('/api/portal/certificados', authMiddleware, async (req, res) => { try { const { rows } = await pool.query( `SELECT * FROM certificados WHERE aluno_id = $1 ORDER BY created_at DESC`, [req.user.studentId] ); const certificates = rows.map((c) => ({ id: c.id, studentId: c.aluno_id, description: c.descricao, frontImage: normalizeStorageUrl(c.imagem_frente), backImage: normalizeStorageUrl(c.imagem_verso), issueDate: c.data_emissao ? new Date(c.data_emissao).toISOString() : c.created_at, frontOverlays: c.overlays_frente, backOverlays: c.overlays_verso, })); res.json({ certificates }); } catch (err) { console.error('Erro certificados portal:', err); res.status(500).json({ error: 'Erro interno' }); } }); // GET /api/portal/config — Agora retorna dados do PostgreSQL, não mais Supabase app.get('/api/portal/config', (req, res) => { // O frontend usava isso para Supabase Realtime. // No self-hosted, o frontend usará polling ou SSE. res.json({ supabaseUrl: null, supabaseAnonKey: null, selfHosted: true, }); }); // GET /api/portal/aulas (SQL-First — Leitura direta do PostgreSQL) app.get('/api/portal/aulas', authMiddleware, async (req, res) => { try { const { rows: dbStudents } = await pool.query('SELECT turma_id FROM alunos WHERE id = $1', [req.user.studentId]); if (dbStudents.length === 0) return res.json({ lessons: [] }); // Obter turmas do aluno a partir da turma atual e presenças históricas const { rows: freqRows } = await pool.query('SELECT DISTINCT turma_id FROM frequencias WHERE aluno_id = $1', [req.user.studentId]); const studentClassIds = new Set([ dbStudents[0].turma_id, ...freqRows.map(f => f.turma_id) ].filter(Boolean)); if (studentClassIds.size === 0) return res.json({ lessons: [] }); const classIdsArray = Array.from(studentClassIds); const { rows: aulasRows } = await pool.query(` SELECT a.*, TO_CHAR(a.data, 'YYYY-MM-DD') as data_formatada, t.nome as class_name FROM aulas a LEFT JOIN turmas t ON a.turma_id = t.id WHERE a.turma_id = ANY($1) ORDER BY a.data ASC, a.horario_inicio ASC `, [classIdsArray]); const lessons = aulasRows.map(row => ({ id: row.id, classId: row.turma_id, date: row.data_formatada, startTime: row.horario_inicio, endTime: row.horario_fim, status: row.status, type: row.tipo, cancellationReason: row.motivo_cancelamento, originalLessonId: row.aula_original_id, className: row.class_name || 'Turma' })); res.json({ lessons }); } catch (err) { console.error('Erro ao buscar aulas:', err); res.status(500).json({ error: 'Erro interno' }); } }); // GET /api/portal/notificacoes app.get('/api/portal/notificacoes', authMiddleware, async (req, res) => { try { const { rows } = await pool.query( `SELECT id, 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`, [req.user.studentId] ); res.json({ notifications: rows }); } catch (err) { console.error('Erro ao buscar notificações:', err); res.status(500).json({ error: 'Erro interno' }); } }); // PUT /api/portal/notificacoes/ler/:id app.put('/api/portal/notificacoes/ler/:id', authMiddleware, async (req, res) => { try { const { id } = req.params; await pool.query( 'UPDATE notificacoes SET lida = true WHERE id = $1 AND aluno_id = $2', [id, req.user.studentId] ); res.json({ success: true }); } catch (err) { console.error('Erro ao marcar notificação como lida:', err); res.status(500).json({ error: 'Erro interno' }); } }); // DELETE /api/portal/notificacoes/:id app.delete('/api/portal/notificacoes/:id', authMiddleware, async (req, res) => { try { const { id } = req.params; await pool.query( 'DELETE FROM notificacoes WHERE id = $1 AND aluno_id = $2', [id, req.user.studentId] ); res.json({ success: true }); } catch (err) { console.error('Erro ao deletar notificação:', err); res.status(500).json({ error: 'Erro interno' }); } }); // PUT /api/portal/alterar-senha app.get('/api/portal/alterar-senha', (req, res) => { // express compat }); app.put('/api/portal/alterar-senha', authMiddleware, async (req, res) => { try { const { currentPassword, newPassword } = req.body; if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Campos obrigatórios' }); if (newPassword.length < 4) return res.status(400).json({ error: 'Mínimo 4 caracteres' }); const { rows: dbStudents } = await pool.query('SELECT id, senha_portal, cpf FROM alunos WHERE id = $1', [req.user.studentId]); if (dbStudents.length === 0) return res.status(404).json({ error: 'Aluno não encontrado' }); const student = dbStudents[0]; const expectedPassword = student.senha_portal || (student.cpf ? student.cpf.replace(/\D/g, '').substring(0, 6) : ''); if (currentPassword !== expectedPassword) return res.status(401).json({ error: 'Senha atual incorreta' }); await pool.query('UPDATE alunos SET senha_portal = $1 WHERE id = $2', [newPassword, req.user.studentId]); res.json({ message: 'Senha alterada com sucesso' }); } catch (err) { console.error('Alterar senha error:', err); res.status(500).json({ error: 'Erro ao alterar senha' }); } }); // =================================================== // AVALIAÇÕES (Exams) — PostgreSQL direto para submissões // =================================================== app.get('/api/portal/avaliacoes', authMiddleware, async (req, res) => { try { const { rows: dbStudents } = await pool.query('SELECT turma_id FROM alunos WHERE id = $1', [req.user.studentId]); if (dbStudents.length === 0) return res.json({ exams: [], submissions: [] }); const classId = dbStudents[0].turma_id; const { rows: dbExams } = await pool.query( `SELECT * FROM provas WHERE turma_id = $1 AND status = 'published' AND is_deleted = false`, [classId] ); const exams = []; for (const row of dbExams) { const { rows: questoes } = await pool.query( `SELECT * FROM questoes_provas WHERE prova_id = $1 ORDER BY ordem ASC`, [row.id] ); exams.push({ id: row.id, classId: row.turma_id, subjectId: row.disciplina_id, periodId: row.periodo_id, title: row.titulo, durationMinutes: row.duracao_minutos, status: row.status, allowRetake: row.permitir_refacao, isDeleted: row.is_deleted, evaluationType: row.evaluation_type, maxScore: row.max_score !== null && row.max_score !== undefined ? Number(row.max_score) : 10, questions: questoes.map(q => ({ id: q.id, text: q.texto, options: typeof q.opcoes === 'string' ? JSON.parse(q.opcoes) : q.opcoes, correctOptionIndex: q.indice_correto, imageUrl: normalizeStorageUrl(q.imagem_url) })) }); } const { rows: submissions } = await pool.query( 'SELECT * FROM provas_submissoes WHERE aluno_id = $1', [req.user.studentId] ); // Mapear nomes de colunas do banco para o formato esperado pelo frontend // IMPORTANTE: NUMERIC(5,2) retorna como string do pg, precisa de Number() const mappedSubmissions = (submissions || []).map(s => ({ ...s, exam_id: s.prova_id || s.exam_id, total_questions: s.total_questoes || s.total_questions, correct_count: s.acertos || s.correct_count, wrong_count: s.erros || s.wrong_count, percentage: Number(s.percentual || s.percentage || 0), final_score: Number(s.nota_final || s.final_score || 0), answers_json: s.respostas || s.answers_json, })); res.json({ exams, submissions: mappedSubmissions }); } catch (err) { console.error('Avaliacoes error:', err); res.status(500).json({ error: 'Erro interno' }); } }); app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) => { try { const { examId, answers } = req.body; if (!examId || !answers) return res.status(400).json({ error: 'Dados obrigatórios' }); const { rows: examRows } = await pool.query('SELECT * FROM provas WHERE id = $1', [examId]); if (examRows.length === 0) return res.status(404).json({ error: 'Prova não encontrada.' }); const examData = examRows[0]; const { rows: questoes } = await pool.query('SELECT * FROM questoes_provas WHERE prova_id = $1 ORDER BY ordem ASC', [examId]); const exam = { id: examData.id, title: examData.titulo, allowRetake: examData.permitir_refacao, subjectId: examData.disciplina_id, periodId: examData.periodo_id, evaluationType: examData.evaluation_type, maxScore: examData.max_score !== null && examData.max_score !== undefined ? Number(examData.max_score) : 10, questions: questoes.map(q => ({ id: q.id, correctOptionIndex: q.indice_correto })) }; // 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; let correctCount = 0; for (const q of exam.questions) { if (answers[q.id] !== undefined && answers[q.id] === q.correctOptionIndex) correctCount++; } const wrongCount = totalQuestions - correctCount; const percentage = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * 100).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 await pool.query( `INSERT INTO provas_submissoes (aluno_id, prova_id, total_questoes, acertos, erros, percentual, nota_final, respostas, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [req.user.studentId, examId, totalQuestions, correctCount, wrongCount, percentage, finalScore, JSON.stringify(answers), new Date().toISOString()] ); // Integrar com notas_boletim (Nova Tabela) em vez de school_data if (exam.subjectId && exam.periodId) { try { await pool.query( `INSERT INTO notas_boletim (aluno_id, disciplina_id, periodo_id, prova_id, valor, updated_at) VALUES ($1, $2, $3, $4, $5, NOW()) ON CONFLICT (aluno_id, disciplina_id, periodo_id, prova_id) DO UPDATE SET valor = EXCLUDED.valor, updated_at = NOW()`, [req.user.studentId, exam.subjectId, exam.periodId, examId, finalScore] ); } catch (gradeErr) { console.error('[Portal:Submissão] Erro ao salvar nota no boletim:', gradeErr.message); } } // Inserir notificação para o ADMIN no Sino try { const { rows: info } = await pool.query( `SELECT a.nome as student_name, t.nome as class_name FROM alunos a LEFT JOIN turmas t ON a.turma_id = t.id WHERE a.id = $1`, [req.user.studentId] ); const studentName = info[0]?.student_name || req.user.name || 'Aluno'; const className = info[0]?.class_name || 'Turma não identificada'; const typeLabel = exam.evaluationType === 'activity' ? 'Atividade' : 'Prova'; await pool.query( `INSERT INTO notificacoes (aluno_id, titulo, mensagem, anexo, lida, created_at) VALUES ($1, $2, $3, $4, $5, NOW())`, [ 'admin', `📝 ${typeLabel} Finalizada`, `${studentName} (${className}) finalizou a ${typeLabel.toLowerCase()} "${exam.title}" com nota ${finalScore}.`, JSON.stringify({ type: 'exam', studentId: req.user.studentId, examId, grade: finalScore }), false ] ); } catch (notifErr) { console.error('[Portal:Submissão] Erro ao disparar notificação admin:', notifErr.message); } res.json({ success: true, result: { total_questions: totalQuestions, correct_count: correctCount, wrong_count: wrongCount, percentage, final_score: finalScore } }); } catch (err) { console.error('❌ [Portal:Submissão] Erro crítico ao salvar:', err.message, err.stack); res.status(500).json({ error: 'Erro interno ao processar nota', details: err.message, code: 'DB_SAVE_ERROR' }); } }); // =================================================== // PWA ENDPOINTS (Manifest & Service Worker) // =================================================== app.get('/manifest.json', async (req, res) => { try { let name = 'EduManager'; let logo = '/vite.svg'; const { rows } = await pool.query('SELECT * FROM configuracoes ORDER BY tipo DESC, nome ASC LIMIT 1'); if (rows.length > 0) { name = rows[0].nome || 'EduManager'; logo = normalizeStorageUrl(rows[0].logo) || '/vite.svg'; } // Detectar dinamicamente o tipo MIME com base na extensão do arquivo de logo (ex: webp, png, svg) const ext = logo.split('.').pop().split('?')[0].toLowerCase(); let mimeType = 'image/png'; if (ext === 'jpg' || ext === 'jpeg') mimeType = 'image/jpeg'; else if (ext === 'svg') mimeType = 'image/svg+xml'; else if (ext === 'webp') mimeType = 'image/webp'; res.json({ "short_name": name, "name": `${name} - Portal do Aluno`, "icons": [ { "src": logo, "sizes": "192x192", "type": mimeType, "purpose": "any" }, { "src": logo, "sizes": "512x512", "type": mimeType, "purpose": "any" }, { "src": logo, "sizes": "192x192", "type": mimeType, "purpose": "maskable" }, { "src": logo, "sizes": "512x512", "type": mimeType, "purpose": "maskable" } ], "start_url": "/", "background_color": "#0f172a", "theme_color": "#4f46e5", "display": "standalone", "orientation": "portrait" }); } catch (e) { res.json({ "short_name": "EduManager", "name": "EduManager - Portal do Aluno", "icons": [ { "src": "/vite.svg", "sizes": "192x192", "type": "image/svg+xml", "purpose": "any" }, { "src": "/vite.svg", "sizes": "512x512", "type": "image/svg+xml", "purpose": "any" } ], "start_url": "/", "background_color": "#0f172a", "theme_color": "#4f46e5", "display": "standalone", "orientation": "portrait" }); } }); app.get('/sw.js', (req, res) => { res.set('Content-Type', 'application/javascript'); res.send(` const CACHE_NAME = 'edumanager-portal-v1'; self.addEventListener('install', (e) => { self.skipWaiting(); }); self.addEventListener('activate', (e) => { e.waitUntil(self.clients.claim()); }); self.addEventListener('fetch', (e) => { e.respondWith(fetch(e.request).catch(() => caches.match(e.request))); }); `); }); // =================================================== // SERVE FRONTEND // =================================================== const distPath = path.join(__dirname, 'dist'); app.use(express.static(distPath)); app.use((req, res) => { res.sendFile(path.join(distPath, 'index.html')); }); // =================================================== // START SERVER // =================================================== app.listen(PORT, () => { console.log(`🚀 Portal do Aluno Self-Hosted na porta ${PORT}`); console.log(`📡 PostgreSQL: ${DATABASE_URL.split('@')[1] || 'local'}`); });