edumanagerpro2/portal/server.selfhosted.js

1094 lines
38 KiB
JavaScript

/**
* ============================================================
* 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 vencimento ASC`,
[req.user.studentId]
);
dbRows = rows || [];
} catch (dbErr) {
console.error('Financeiro: erro ao buscar do PostgreSQL -', dbErr.message);
}
// 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 });
} 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 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'}`);
});