- Added a floating help button and modal for user guidance. - Improved styling for result cards and added match score badges. - Refactored CSS for better readability and organization. - Updated HTML structure to accommodate new features and improve accessibility. - Implemented JavaScript functions for modal management and collapsible sections. - Marked 'pg' package as a peer dependency in package-lock files.
527 lines
17 KiB
JavaScript
527 lines
17 KiB
JavaScript
// Carrega as variáveis de ambiente do arquivo .env
|
|
require('dotenv').config();
|
|
|
|
const express = require('express');
|
|
const cors = require('cors');
|
|
const { Pool } = require('pg');
|
|
const TABLENAME = 'candidatos_sisu_enem';
|
|
|
|
const app = express();
|
|
app.use(cors());
|
|
app.use(express.static('public'));
|
|
|
|
// Pool de conexões OTIMIZADO com configurações robustas
|
|
const pool = new Pool({
|
|
connectionString: process.env.DATABASE_URL,
|
|
max: 20, // Máximo de conexões
|
|
min: 2, // Mínimo de conexões sempre ativas
|
|
idleTimeoutMillis: 30000, // 30 segundos
|
|
connectionTimeoutMillis: 5000, // 5 segundos para obter conexão
|
|
keepAlive: true, // Mantém conexões vivas
|
|
keepAliveInitialDelayMillis: 10000, // Delay inicial do keepalive
|
|
});
|
|
|
|
// Event listeners para debug
|
|
pool.on('error', (err, client) => {
|
|
console.error('❌ Erro inesperado no pool de conexões:', err);
|
|
});
|
|
|
|
pool.on('connect', () => {
|
|
console.log('✅ Nova conexão estabelecida com o banco');
|
|
});
|
|
|
|
// Função auxiliar para executar queries com RETRY automático
|
|
async function executeQueryWithRetry(queryText, params, maxRetries = 3) {
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
const result = await pool.query(queryText, params);
|
|
return result;
|
|
} catch (error) {
|
|
console.error(`❌ Tentativa ${attempt} falhou:`, error.message);
|
|
|
|
// Se for o último retry, lança o erro
|
|
if (attempt === maxRetries) {
|
|
throw error;
|
|
}
|
|
|
|
// Aguarda antes de tentar novamente (backoff exponencial)
|
|
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
|
|
console.log(`⏳ Aguardando ${delay}ms antes de tentar novamente...`);
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Função para normalizar texto (remove acentos)
|
|
function normalizeText(text) {
|
|
return text
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '');
|
|
}
|
|
|
|
// Validação e sanitização de inputs
|
|
function validateSearchParams(nomes, page) {
|
|
const trimmedNomes = nomes.trim();
|
|
|
|
if (!trimmedNomes || trimmedNomes.length < 2) {
|
|
return { valid: false, error: 'Nome deve ter pelo menos 2 caracteres' };
|
|
}
|
|
|
|
const pageNumber = parseInt(page, 10);
|
|
if (isNaN(pageNumber) || pageNumber < 1) {
|
|
return { valid: false, error: 'Página inválida' };
|
|
}
|
|
|
|
return { valid: true, nomes: trimmedNomes, pageNumber };
|
|
}
|
|
|
|
// Função para gerar condições de busca por palavras
|
|
function buildWordSearchConditions(searchTerm) {
|
|
const words = searchTerm
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter(word => word.length >= 2);
|
|
|
|
if (words.length === 0) return null;
|
|
|
|
const conditions = words.map(() => `sisu_no_inscrito ILIKE ?`).join(' AND ');
|
|
const params = words.map(word => `%${word}%`);
|
|
|
|
return { conditions, params, words };
|
|
}
|
|
|
|
// ============= BUSCA POR NOME =============
|
|
app.get('/buscar', async (req, res) => {
|
|
const { nomes = '', page = 1 } = req.query;
|
|
|
|
const validation = validateSearchParams(nomes, page);
|
|
if (!validation.valid) {
|
|
return res.status(400).json({ message: validation.error });
|
|
}
|
|
|
|
const { nomes: sanitizedNomes, pageNumber } = validation;
|
|
const limit = 50;
|
|
const offset = (pageNumber - 1) * limit;
|
|
|
|
try {
|
|
let queryText;
|
|
let countQueryText;
|
|
let queryParams;
|
|
|
|
if (sanitizedNomes.includes(';')) {
|
|
const searchTerms = sanitizedNomes
|
|
.split(';')
|
|
.map(name => name.trim())
|
|
.filter(name => name.length >= 2);
|
|
|
|
if (searchTerms.length === 0) {
|
|
return res.status(400).json({ message: 'Nenhum termo de busca válido' });
|
|
}
|
|
|
|
const allConditions = [];
|
|
const allParams = [];
|
|
|
|
searchTerms.forEach(term => {
|
|
const wordSearch = buildWordSearchConditions(term);
|
|
if (wordSearch) {
|
|
allConditions.push(`(${wordSearch.conditions})`);
|
|
allParams.push(...wordSearch.params);
|
|
}
|
|
});
|
|
|
|
if (allConditions.length === 0) {
|
|
return res.status(400).json({ message: 'Nenhum termo de busca válido' });
|
|
}
|
|
|
|
const finalCondition = allConditions.join(' OR ');
|
|
let paramIndex = 1;
|
|
const pgCondition = finalCondition.replace(/\?/g, () => `$${paramIndex++}`);
|
|
|
|
queryText = `
|
|
SELECT
|
|
sisu_no_inscrito, sisu_no_curso, sisu_no_ies, sisu_sg_uf_ies, sisu_no_campus,
|
|
sisu_ds_turno, sisu_ds_formacao, sisu_nu_nota_candidato, sisu_nu_notacorte_concorrida,
|
|
sisu_nu_classificacao, sisu_qt_vagas_concorrencia, sisu_no_modalidade_concorrencia,
|
|
enem_nu_nota_cn, enem_nu_nota_ch, enem_nu_nota_lc, enem_nu_nota_mt, enem_nu_nota_redacao,
|
|
enem_nu_nota_comp1, enem_nu_nota_comp2, enem_nu_nota_comp3, enem_nu_nota_comp4, enem_nu_nota_comp5,
|
|
enem_media_enem, match_score, enem_no_municipio_esc, enem_sg_uf_esc,
|
|
sisu_ensino_medio, sisu_quilombola, sisu_deficiente, sisu_tipo_concorrencia,
|
|
sisu_st_bonus_perc, sisu_qt_bonus_perc
|
|
FROM ${TABLENAME}
|
|
WHERE ${pgCondition}
|
|
ORDER BY sisu_no_inscrito
|
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
|
|
|
|
countQueryText = `
|
|
SELECT COUNT(*) FROM ${TABLENAME}
|
|
WHERE ${pgCondition}`;
|
|
|
|
queryParams = [...allParams, limit, offset];
|
|
|
|
} else {
|
|
const wordSearch = buildWordSearchConditions(sanitizedNomes);
|
|
|
|
if (!wordSearch) {
|
|
return res.status(400).json({ message: 'Termo de busca inválido' });
|
|
}
|
|
|
|
const { conditions, params, words } = wordSearch;
|
|
let paramIndex = 1;
|
|
const pgConditions = conditions.replace(/\?/g, () => `$${paramIndex++}`);
|
|
|
|
const firstWord = words[0];
|
|
const searchTermStart = `${firstWord}%`;
|
|
const searchTermWordStart = `% ${firstWord}%`;
|
|
|
|
queryText = `
|
|
SELECT
|
|
sisu_no_inscrito, sisu_no_curso, sisu_no_ies, sisu_sg_uf_ies, sisu_no_campus,
|
|
sisu_ds_turno, sisu_ds_formacao, sisu_nu_nota_candidato, sisu_nu_notacorte_concorrida,
|
|
sisu_nu_classificacao, sisu_qt_vagas_concorrencia, sisu_no_modalidade_concorrencia,
|
|
enem_nu_nota_cn, enem_nu_nota_ch, enem_nu_nota_lc, enem_nu_nota_mt, enem_nu_nota_redacao,
|
|
enem_nu_nota_comp1, enem_nu_nota_comp2, enem_nu_nota_comp3, enem_nu_nota_comp4, enem_nu_nota_comp5,
|
|
enem_media_enem, match_score, enem_no_municipio_esc, enem_sg_uf_esc,
|
|
sisu_ensino_medio, sisu_quilombola, sisu_deficiente, sisu_tipo_concorrencia,
|
|
sisu_st_bonus_perc, sisu_qt_bonus_perc
|
|
FROM ${TABLENAME}
|
|
WHERE ${pgConditions}
|
|
ORDER BY
|
|
CASE
|
|
WHEN LOWER(sisu_no_inscrito) LIKE LOWER($${paramIndex}) THEN 1
|
|
WHEN LOWER(sisu_no_inscrito) LIKE LOWER($${paramIndex + 1}) THEN 2
|
|
ELSE 3
|
|
END,
|
|
LENGTH(sisu_no_inscrito),
|
|
sisu_no_inscrito
|
|
LIMIT $${paramIndex + 2} OFFSET $${paramIndex + 3}`;
|
|
|
|
countQueryText = `
|
|
SELECT COUNT(*) FROM ${TABLENAME}
|
|
WHERE ${pgConditions}`;
|
|
|
|
queryParams = [...params, searchTermStart, searchTermWordStart, limit, offset];
|
|
}
|
|
|
|
const resultsPromise = executeQueryWithRetry(queryText, queryParams);
|
|
const countParams = sanitizedNomes.includes(';')
|
|
? queryParams.slice(0, -2)
|
|
: queryParams.slice(0, -4);
|
|
const countPromise = executeQueryWithRetry(countQueryText, countParams);
|
|
|
|
const [results, totalCountResult] = await Promise.all([
|
|
resultsPromise,
|
|
countPromise
|
|
]);
|
|
|
|
const totalResults = parseInt(totalCountResult.rows[0].count, 10);
|
|
const totalPages = Math.ceil(totalResults / limit);
|
|
|
|
res.json({
|
|
data: results.rows,
|
|
pagination: {
|
|
totalResults,
|
|
totalPages,
|
|
currentPage: pageNumber
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('❌ Erro na busca no banco:', error);
|
|
res.status(500).json({ message: 'Erro ao buscar os dados. Tente novamente.' });
|
|
}
|
|
});
|
|
|
|
// ============= FILTROS AVANÇADOS =============
|
|
app.get('/filtros', async (req, res) => {
|
|
const {
|
|
curso = '',
|
|
uf = '',
|
|
notaMin = '',
|
|
turno = '',
|
|
page = 1
|
|
} = req.query;
|
|
|
|
const limit = 50;
|
|
const pageNumber = parseInt(page, 10) || 1;
|
|
const offset = (pageNumber - 1) * limit;
|
|
|
|
try {
|
|
let conditions = [];
|
|
let params = [];
|
|
let paramIndex = 1;
|
|
|
|
if (curso && curso.trim()) {
|
|
const normalizedCurso = normalizeText(curso.trim());
|
|
conditions.push(`
|
|
LOWER(TRANSLATE(sisu_no_curso,
|
|
'áàâãäéèêëíìîïóòôõöúùûüçñÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇÑ',
|
|
'aaaaaeeeeiiiiooooouuuucnAAAAAEEEEIIIIOOOOOUUUUCN'
|
|
)) LIKE $${paramIndex}
|
|
`);
|
|
params.push(`%${normalizedCurso}%`);
|
|
paramIndex++;
|
|
}
|
|
|
|
if (uf && uf.trim()) {
|
|
conditions.push(`sisu_sg_uf_ies = $${paramIndex}`);
|
|
params.push(uf.trim().toUpperCase());
|
|
paramIndex++;
|
|
}
|
|
|
|
if (notaMin && notaMin.trim() && parseFloat(notaMin) > 0) {
|
|
conditions.push(`sisu_nu_nota_candidato >= $${paramIndex}`);
|
|
params.push(parseFloat(notaMin));
|
|
paramIndex++;
|
|
}
|
|
|
|
if (turno && turno.trim()) {
|
|
conditions.push(`sisu_ds_turno ILIKE $${paramIndex}`);
|
|
params.push(`%${turno.trim()}%`);
|
|
paramIndex++;
|
|
}
|
|
|
|
if (conditions.length === 0) {
|
|
return res.status(400).json({
|
|
message: 'Selecione pelo menos um filtro para buscar.'
|
|
});
|
|
}
|
|
|
|
const whereClause = conditions.join(' AND ');
|
|
|
|
const queryText = `
|
|
SELECT
|
|
sisu_no_inscrito, sisu_no_curso, sisu_no_ies, sisu_sg_uf_ies, sisu_no_campus,
|
|
sisu_ds_turno, sisu_ds_formacao, sisu_nu_nota_candidato, sisu_nu_notacorte_concorrida,
|
|
sisu_nu_classificacao, sisu_qt_vagas_concorrencia, sisu_no_modalidade_concorrencia,
|
|
enem_nu_nota_cn, enem_nu_nota_ch, enem_nu_nota_lc, enem_nu_nota_mt, enem_nu_nota_redacao,
|
|
enem_nu_nota_comp1, enem_nu_nota_comp2, enem_nu_nota_comp3, enem_nu_nota_comp4, enem_nu_nota_comp5,
|
|
enem_media_enem, match_score, enem_no_municipio_esc, enem_sg_uf_esc,
|
|
sisu_ensino_medio, sisu_quilombola, sisu_deficiente, sisu_tipo_concorrencia,
|
|
sisu_st_bonus_perc, sisu_qt_bonus_perc
|
|
FROM ${TABLENAME}
|
|
WHERE ${whereClause}
|
|
ORDER BY sisu_nu_nota_candidato DESC
|
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
|
|
|
|
const countQuery = `
|
|
SELECT COUNT(*) FROM ${TABLENAME}
|
|
WHERE ${whereClause}`;
|
|
|
|
params.push(limit, offset);
|
|
|
|
const [results, countResult] = await Promise.all([
|
|
executeQueryWithRetry(queryText, params),
|
|
executeQueryWithRetry(countQuery, params.slice(0, -2))
|
|
]);
|
|
|
|
const totalResults = parseInt(countResult.rows[0].count, 10);
|
|
const totalPages = Math.ceil(totalResults / limit);
|
|
|
|
res.json({
|
|
data: results.rows,
|
|
pagination: {
|
|
totalResults,
|
|
totalPages,
|
|
currentPage: pageNumber
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('❌ Erro nos filtros:', error);
|
|
res.status(500).json({ message: 'Erro ao filtrar dados. Tente novamente.' });
|
|
}
|
|
});
|
|
|
|
// ============= TOP 10 CURSOS MAIS CONCORRIDOS =============
|
|
app.get('/stats/cursos-concorridos', async (req, res) => {
|
|
try {
|
|
const query = `
|
|
SELECT
|
|
sisu_no_curso,
|
|
sisu_no_ies,
|
|
sisu_sg_uf_ies,
|
|
MAX(sisu_nu_notacorte_concorrida) as nota_corte,
|
|
COUNT(*) as total_aprovados,
|
|
ROUND(AVG(sisu_nu_nota_candidato)::numeric, 2) as media_aprovados
|
|
FROM ${TABLENAME}
|
|
WHERE sisu_nu_notacorte_concorrida > 0
|
|
GROUP BY sisu_no_curso, sisu_no_ies, sisu_sg_uf_ies
|
|
ORDER BY nota_corte DESC
|
|
LIMIT 10`;
|
|
|
|
const result = await executeQueryWithRetry(query, []);
|
|
res.json({ data: result.rows });
|
|
} catch (error) {
|
|
console.error('❌ Erro ao buscar cursos concorridos:', error);
|
|
res.status(500).json({ message: 'Erro ao buscar estatísticas. Tente novamente.' });
|
|
}
|
|
});
|
|
|
|
// ============= TOP 10 UNIVERSIDADES =============
|
|
app.get('/stats/universidades', async (req, res) => {
|
|
try {
|
|
const query = `
|
|
SELECT
|
|
sisu_no_ies,
|
|
sisu_sg_uf_ies,
|
|
ROUND(AVG(sisu_nu_nota_candidato)::numeric, 2) as media_notas,
|
|
COUNT(*) as total_aprovados,
|
|
MAX(sisu_nu_nota_candidato) as maior_nota,
|
|
MIN(sisu_nu_nota_candidato) as menor_nota,
|
|
COUNT(DISTINCT sisu_no_curso) as total_cursos
|
|
FROM ${TABLENAME}
|
|
WHERE sisu_nu_nota_candidato IS NOT NULL
|
|
GROUP BY sisu_no_ies, sisu_sg_uf_ies
|
|
HAVING COUNT(*) >= 50
|
|
ORDER BY media_notas DESC
|
|
LIMIT 10`;
|
|
|
|
const result = await executeQueryWithRetry(query, []);
|
|
res.json({ data: result.rows });
|
|
} catch (error) {
|
|
console.error('❌ Erro ao buscar universidades:', error);
|
|
res.status(500).json({ message: 'Erro ao buscar estatísticas. Tente novamente.' });
|
|
}
|
|
});
|
|
|
|
// ============= TOP 10 MAIORES NOTAS =============
|
|
app.get('/stats/maiores-notas', async (req, res) => {
|
|
try {
|
|
const query = `
|
|
SELECT
|
|
sisu_no_inscrito, sisu_no_curso, sisu_no_ies, sisu_sg_uf_ies, sisu_no_campus,
|
|
sisu_ds_turno, sisu_ds_formacao, sisu_nu_nota_candidato, sisu_nu_notacorte_concorrida,
|
|
sisu_nu_classificacao, sisu_qt_vagas_concorrencia, sisu_no_modalidade_concorrencia,
|
|
enem_nu_nota_cn, enem_nu_nota_ch, enem_nu_nota_lc, enem_nu_nota_mt, enem_nu_nota_redacao,
|
|
enem_nu_nota_comp1, enem_nu_nota_comp2, enem_nu_nota_comp3, enem_nu_nota_comp4, enem_nu_nota_comp5,
|
|
enem_media_enem, match_score, enem_no_municipio_esc, enem_sg_uf_esc,
|
|
sisu_ensino_medio, sisu_quilombola, sisu_deficiente, sisu_tipo_concorrencia,
|
|
sisu_st_bonus_perc, sisu_qt_bonus_perc
|
|
FROM ${TABLENAME}
|
|
WHERE sisu_nu_nota_candidato IS NOT NULL
|
|
ORDER BY sisu_nu_nota_candidato DESC
|
|
LIMIT 10`;
|
|
|
|
const result = await executeQueryWithRetry(query, []);
|
|
res.json({ data: result.rows });
|
|
} catch (error) {
|
|
console.error('❌ Erro ao buscar maiores notas:', error);
|
|
res.status(500).json({ message: 'Erro ao buscar estatísticas. Tente novamente.' });
|
|
}
|
|
});
|
|
|
|
// ============= ESTATÍSTICAS POR ESTADO =============
|
|
app.get('/stats/estado/:uf', async (req, res) => {
|
|
const { uf } = req.params;
|
|
|
|
try {
|
|
// Query 1: Dados gerais do estado
|
|
const queryGeral = `
|
|
SELECT
|
|
COUNT(*) as total_aprovados,
|
|
ROUND(AVG(sisu_nu_nota_candidato)::numeric, 2) as media_notas,
|
|
MAX(sisu_nu_nota_candidato) as maior_nota,
|
|
MIN(sisu_nu_nota_candidato) as menor_nota,
|
|
COUNT(DISTINCT sisu_no_ies) as total_universidades,
|
|
COUNT(DISTINCT sisu_no_curso) as total_cursos
|
|
FROM ${TABLENAME}
|
|
WHERE sisu_sg_uf_ies = $1 AND sisu_nu_nota_candidato IS NOT NULL`;
|
|
|
|
// Query 2: Top 3 cursos mais concorridos do estado
|
|
const queryTopCursos = `
|
|
SELECT
|
|
sisu_no_curso,
|
|
sisu_no_ies,
|
|
MAX(sisu_nu_notacorte_concorrida) as nota_corte,
|
|
COUNT(*) as total_aprovados
|
|
FROM ${TABLENAME}
|
|
WHERE sisu_sg_uf_ies = $1 AND sisu_nu_notacorte_concorrida > 0
|
|
GROUP BY sisu_no_curso, sisu_no_ies
|
|
ORDER BY nota_corte DESC
|
|
LIMIT 3`;
|
|
|
|
// Query 3: Distribuição por modalidade
|
|
const queryModalidades = `
|
|
SELECT
|
|
sisu_no_modalidade_concorrencia,
|
|
COUNT(*) as total
|
|
FROM ${TABLENAME}
|
|
WHERE sisu_sg_uf_ies = $1
|
|
GROUP BY sisu_no_modalidade_concorrencia
|
|
ORDER BY total DESC
|
|
LIMIT 5`;
|
|
|
|
// Query 4: Top 3 universidades do estado
|
|
const queryTopUniversidades = `
|
|
SELECT
|
|
sisu_no_ies,
|
|
COUNT(*) as total_aprovados,
|
|
ROUND(AVG(sisu_nu_nota_candidato)::numeric, 2) as media_notas
|
|
FROM ${TABLENAME}
|
|
WHERE sisu_sg_uf_ies = $1 AND sisu_nu_nota_candidato IS NOT NULL
|
|
GROUP BY sisu_no_ies
|
|
ORDER BY total_aprovados DESC
|
|
LIMIT 3`;
|
|
|
|
const [geral, topCursos, modalidades, topUniversidades] = await Promise.all([
|
|
executeQueryWithRetry(queryGeral, [uf.toUpperCase()]),
|
|
executeQueryWithRetry(queryTopCursos, [uf.toUpperCase()]),
|
|
executeQueryWithRetry(queryModalidades, [uf.toUpperCase()]),
|
|
executeQueryWithRetry(queryTopUniversidades, [uf.toUpperCase()])
|
|
]);
|
|
|
|
res.json({
|
|
estado: uf.toUpperCase(),
|
|
geral: geral.rows[0],
|
|
topCursos: topCursos.rows,
|
|
modalidades: modalidades.rows,
|
|
topUniversidades: topUniversidades.rows
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('❌ Erro ao buscar dados do estado:', error);
|
|
res.status(500).json({ message: 'Erro ao buscar dados do estado.' });
|
|
}
|
|
});
|
|
|
|
// ============= HEALTH CHECK =============
|
|
app.get('/health', async (req, res) => {
|
|
try {
|
|
await pool.query('SELECT 1');
|
|
res.json({
|
|
status: 'ok',
|
|
timestamp: new Date().toISOString(),
|
|
poolSize: pool.totalCount,
|
|
idleConnections: pool.idleCount,
|
|
waitingClients: pool.waitingCount
|
|
});
|
|
} catch (error) {
|
|
res.status(503).json({
|
|
status: 'error',
|
|
message: 'Database unavailable',
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGTERM', async () => {
|
|
console.log('⏳ SIGTERM recebido, fechando servidor...');
|
|
await pool.end();
|
|
console.log('✅ Pool de conexões fechado');
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on('SIGINT', async () => {
|
|
console.log('⏳ SIGINT recebido, fechando servidor...');
|
|
await pool.end();
|
|
console.log('✅ Pool de conexões fechado');
|
|
process.exit(0);
|
|
});
|
|
|
|
const PORT = process.env.PORT || 8080;
|
|
app.listen(PORT, () => {
|
|
console.log(`🚀 SisuData rodando na porta ${PORT}!`);
|
|
console.log(`📊 Pool configurado: max=${pool.options.max}, min=${pool.options.min || 0}`);
|
|
});
|