feat: completar Fase 2 del Roadmap MVP (Co-docencia, Matriz de Conflictos y Regla Diaria) y auditoria de seguridad
This commit is contained in:
@@ -33,10 +33,37 @@ nunjucksRuntime.memberLookup = function(obj, val) {
|
||||
const cookieParser = require('cookie-parser');
|
||||
const path = require('path');
|
||||
const axios = require('axios');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
app.disable('x-powered-by');
|
||||
|
||||
// Blindaje HTTP con Helmet
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false
|
||||
}));
|
||||
|
||||
// Rate Limiting para protección contra ataques de fuerza bruta y abuso
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 30,
|
||||
message: 'Demasiados intentos de autenticación. Por favor intente nuevamente en 15 minutos.',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
});
|
||||
const generalLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 180,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
});
|
||||
app.use('/auth/login', authLimiter);
|
||||
app.use('/api', generalLimiter);
|
||||
|
||||
// Configuración de middlewares
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
+203
-35
@@ -291,6 +291,7 @@ const MOCK_USERS = [
|
||||
const fetchUsersAndRoles = async (apiClient) => {
|
||||
let users = [...MOCK_USERS];
|
||||
let roles = [...MOCK_ROLES];
|
||||
let document_types = [];
|
||||
try {
|
||||
const [uRes, rRes] = await Promise.allSettled([
|
||||
apiClient.get('/users'),
|
||||
@@ -298,7 +299,8 @@ const fetchUsersAndRoles = async (apiClient) => {
|
||||
]);
|
||||
if (uRes.status === 'fulfilled' && uRes.value.data) {
|
||||
const d = uRes.value.data;
|
||||
users = (d.users || d) || users;
|
||||
users = d.users || (Array.isArray(d) ? d : users);
|
||||
document_types = d.document_types || [];
|
||||
}
|
||||
if (rRes.status === 'fulfilled' && rRes.value.data) {
|
||||
const d = rRes.value.data;
|
||||
@@ -307,21 +309,41 @@ const fetchUsersAndRoles = async (apiClient) => {
|
||||
} catch (e) {
|
||||
console.warn('Using mock users/roles data:', e.message);
|
||||
}
|
||||
return { users, roles };
|
||||
if (!document_types || document_types.length === 0) {
|
||||
document_types = [
|
||||
{ code: 'DNI', name: 'Documento Nacional de Identidad', category: 'Nacional', placeholder: '8 dígitos sin puntos' },
|
||||
{ code: 'CUIL', name: 'Código Único de Identificación Laboral (CUIL/CUIT)', category: 'Nacional / Laboral', placeholder: '20-12345678-9' },
|
||||
{ code: 'PAS', name: 'Pasaporte Argentino', category: 'Internacional', placeholder: 'AAB123456' },
|
||||
{ code: 'PASEXT', name: 'Pasaporte Extranjero', category: 'Internacional / Extranjero', placeholder: 'Alfanumérico país de origen' },
|
||||
{ code: 'DNIEXT', name: 'DNI para Extranjeros (Residente)', category: 'Extranjero / Residente', placeholder: 'DNI residente' },
|
||||
{ code: 'CI', name: 'Cédula de Identidad (PFA / Prov)', category: 'Nacional (Histórico)', placeholder: 'Cédula de identidad' },
|
||||
{ code: 'CIEXT', name: 'Cédula de Identidad Extranjera (Mercosur)', category: 'Regional / Mercosur', placeholder: 'Cédula Mercosur' },
|
||||
{ code: 'LC', name: 'Libreta Cívica', category: 'Histórico Nacional', placeholder: 'Libreta Cívica' },
|
||||
{ code: 'LE', name: 'Libreta de Enrolamiento', category: 'Histórico Nacional', placeholder: 'Libreta Enrolamiento' }
|
||||
];
|
||||
}
|
||||
return { users, roles, document_types };
|
||||
};
|
||||
|
||||
const handleUsersList = async (req, res) => {
|
||||
try {
|
||||
const { users, roles } = await fetchUsersAndRoles(req.apiClient);
|
||||
const { users, roles, document_types } = await fetchUsersAndRoles(req.apiClient);
|
||||
const search = (req.query.search || '').toLowerCase().trim();
|
||||
const role_filter = req.query.role || '';
|
||||
const status_filter = req.query.status || '';
|
||||
const msg = req.query.msg || '';
|
||||
const error = req.query.error || '';
|
||||
|
||||
let filtered = [...users];
|
||||
if (search) {
|
||||
filtered = filtered.filter(u =>
|
||||
(u.name && u.name.toLowerCase().includes(search)) ||
|
||||
(u.email && u.email.toLowerCase().includes(search))
|
||||
(u.first_name && u.first_name.toLowerCase().includes(search)) ||
|
||||
(u.last_name && u.last_name.toLowerCase().includes(search)) ||
|
||||
(u.email && u.email.toLowerCase().includes(search)) ||
|
||||
(u.personal_email && u.personal_email.toLowerCase().includes(search)) ||
|
||||
(u.document_number && u.document_number.toLowerCase().includes(search)) ||
|
||||
(u.phone && u.phone.toLowerCase().includes(search))
|
||||
);
|
||||
}
|
||||
if (role_filter) {
|
||||
@@ -331,69 +353,95 @@ const handleUsersList = async (req, res) => {
|
||||
if (status_filter === 'inactive') filtered = filtered.filter(u => !u.is_active);
|
||||
|
||||
res.render('admin/users/list', {
|
||||
title: 'User Management - Edu-Space Admin',
|
||||
title: 'Gestión de Usuarios — Edu-Space Admin',
|
||||
users: filtered,
|
||||
roles,
|
||||
document_types,
|
||||
search,
|
||||
role_filter,
|
||||
status_filter
|
||||
status_filter,
|
||||
msg,
|
||||
error
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error in users list:', err.message);
|
||||
res.render('admin/users/list', {
|
||||
title: 'User Management - Edu-Space Admin',
|
||||
title: 'Gestión de Usuarios — Edu-Space Admin',
|
||||
users: MOCK_USERS,
|
||||
roles: MOCK_ROLES,
|
||||
search: '', role_filter: '', status_filter: ''
|
||||
document_types: [],
|
||||
search: '', role_filter: '', status_filter: '', msg: '', error: ''
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserAdd = async (req, res) => {
|
||||
try {
|
||||
const { roles } = await fetchUsersAndRoles(req.apiClient);
|
||||
const { roles, document_types } = await fetchUsersAndRoles(req.apiClient);
|
||||
res.render('admin/users/form', {
|
||||
title: 'New User - Edu-Space Admin',
|
||||
title: 'Nuevo Usuario — Edu-Space Admin',
|
||||
user: null,
|
||||
roles
|
||||
roles,
|
||||
document_types
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error in user add:', err.message);
|
||||
res.render('admin/users/form', { title: 'New User', user: null, roles: MOCK_ROLES });
|
||||
res.render('admin/users/form', { title: 'Nuevo Usuario', user: null, roles: MOCK_ROLES, document_types: [] });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserEdit = async (req, res) => {
|
||||
try {
|
||||
const { users, roles } = await fetchUsersAndRoles(req.apiClient);
|
||||
const { users, roles, document_types } = await fetchUsersAndRoles(req.apiClient);
|
||||
const userId = req.params.id || req.query.id;
|
||||
const targetUser = (userId ? users.find(u => String(u.id) === String(userId)) : null) || users[0] || MOCK_USERS[0];
|
||||
res.render('admin/users/form', {
|
||||
title: `Edit User: ${targetUser.name} - Edu-Space Admin`,
|
||||
title: `Editar Usuario: ${targetUser.name} — Edu-Space Admin`,
|
||||
user: targetUser,
|
||||
roles
|
||||
roles,
|
||||
document_types
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error in user edit:', err.message);
|
||||
res.render('admin/users/form', { title: 'Edit User', user: MOCK_USERS[0], roles: MOCK_ROLES });
|
||||
res.render('admin/users/form', { title: 'Editar Usuario', user: MOCK_USERS[0], roles: MOCK_ROLES, document_types: [] });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserSave = async (req, res) => {
|
||||
const userId = req.params.id || req.body.id;
|
||||
const { name, email, password, role_id, is_active } = req.body;
|
||||
const payload = { name, email, role_id: parseInt(role_id), is_active: is_active === 'on' || is_active === 'true' };
|
||||
if (password) payload.password = password;
|
||||
const {
|
||||
name, first_name, last_name, email, personal_email, password, role_id, is_active,
|
||||
phone, address, document_type, document_number
|
||||
} = req.body;
|
||||
|
||||
const payload = {
|
||||
first_name: (first_name || '').trim(),
|
||||
last_name: (last_name || '').trim(),
|
||||
name: (name || `${first_name || ''} ${last_name || ''}`).trim(),
|
||||
email: (email || '').trim().toLowerCase(),
|
||||
personal_email: (personal_email || '').trim().toLowerCase(),
|
||||
phone: (phone || '').trim(),
|
||||
address: (address || '').trim(),
|
||||
document_type: (document_type || 'DNI').trim().toUpperCase(),
|
||||
document_number: (document_number || '').trim(),
|
||||
role_id: role_id ? parseInt(role_id, 10) : null,
|
||||
is_active: is_active === 'on' || is_active === 'true' || is_active === true
|
||||
};
|
||||
if (password && password.trim()) {
|
||||
payload.password = password.trim();
|
||||
}
|
||||
try {
|
||||
if (userId) {
|
||||
await req.apiClient.put(`/users/${userId}`, payload);
|
||||
} else {
|
||||
await req.apiClient.post('/users', payload);
|
||||
}
|
||||
return res.redirect('/admin/users_list?msg=saved');
|
||||
} catch (e) {
|
||||
console.warn('User save API notice:', e.response?.data || e.message);
|
||||
const errMsg = encodeURIComponent(e.response?.data?.message || 'Error al guardar el usuario');
|
||||
return res.redirect(`/admin/users_list?error=${errMsg}`);
|
||||
}
|
||||
res.redirect('/admin/users_list');
|
||||
};
|
||||
|
||||
router.get(['/users_list', '/users', '/user_list'], handleUsersList);
|
||||
@@ -632,6 +680,8 @@ const handleSubjectsList = async (req, res) => {
|
||||
career_filter: career_id ? parseInt(career_id, 10) : '',
|
||||
active_filter: active,
|
||||
can_edit: true,
|
||||
msg: req.query.msg || '',
|
||||
error: req.query.error || '',
|
||||
pagination: { pages: 1, page: 1, total: subjects.length, has_prev: false, has_next: false }
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -690,11 +740,13 @@ const handleSubjectToggle = async (req, res) => {
|
||||
const handleSubjectDelete = async (req, res) => {
|
||||
const id = req.params.id || req.body.id;
|
||||
try {
|
||||
await req.apiClient.delete(`/subjects/${id}`);
|
||||
const delRes = await req.apiClient.delete(`/subjects/${id}`);
|
||||
const action = delRes.data?.action || 'deleted';
|
||||
return res.redirect(`/admin/subjects_list?msg=${action}`);
|
||||
} catch (e) {
|
||||
console.warn('Subject delete API notice:', e.message);
|
||||
console.warn('Subject delete API notice:', e.response?.data || e.message);
|
||||
return res.redirect('/admin/subjects_list?error=delete');
|
||||
}
|
||||
res.redirect('/admin/subjects_list');
|
||||
};
|
||||
|
||||
router.get(['/subjects_list', '/subjects', '/subject_list'], handleSubjectsList);
|
||||
@@ -733,7 +785,8 @@ const handleCommissionsList = async (req, res) => {
|
||||
code: c.subject_code,
|
||||
career_name: c.career_name || 'Carrera General'
|
||||
},
|
||||
teacher: c.teacher_name ? { name: c.teacher_name } : null
|
||||
teacher: c.teacher_name ? { name: c.teacher_name } : null,
|
||||
teachers: c.teachers || (c.teacher_name ? [{ name: c.teacher_name, role: 'Titular' }] : [])
|
||||
}));
|
||||
subjects = cRes.value.data.subjects || [];
|
||||
}
|
||||
@@ -776,16 +829,22 @@ const handleCommissionDetail = async (req, res) => {
|
||||
|
||||
try {
|
||||
const [cRes, uRes] = await Promise.allSettled([
|
||||
req.apiClient.get('/commissions'),
|
||||
req.apiClient.get(`/commissions/${id}`),
|
||||
req.apiClient.get('/users')
|
||||
]);
|
||||
if (cRes.status === 'fulfilled' && cRes.value.data) {
|
||||
const list = cRes.value.data.commissions || [];
|
||||
if (cRes.status === 'fulfilled' && cRes.value.data && cRes.value.data.commission) {
|
||||
commission = cRes.value.data.commission;
|
||||
} else {
|
||||
const allRes = await req.apiClient.get('/commissions');
|
||||
const list = allRes.data?.commissions || [];
|
||||
commission = list.find(c => String(c.id) === String(id)) || list[0];
|
||||
}
|
||||
if (uRes.status === 'fulfilled' && uRes.value.data) {
|
||||
const users = uRes.value.data.users || [];
|
||||
teachers = users.filter(u => (u.role || '').toLowerCase().includes('docent') || (u.role || '').toLowerCase().includes('admin'));
|
||||
teachers = users.filter(u => {
|
||||
const r = (u.role || '').toLowerCase();
|
||||
return r.includes('docent') || r.includes('prof') || r.includes('admin') || r.includes('bedel');
|
||||
});
|
||||
}
|
||||
} catch (apiErr) {
|
||||
console.warn('Commission detail API notice:', apiErr.message);
|
||||
@@ -805,27 +864,61 @@ const handleCommissionDetail = async (req, res) => {
|
||||
virtual_link: '',
|
||||
subject_name: 'Arquitectura de Software',
|
||||
subject_code: 'ARQ-101',
|
||||
teacher_name: 'Prof. Juan Pérez'
|
||||
teacher_name: 'Prof. Juan Pérez',
|
||||
teachers: []
|
||||
};
|
||||
}
|
||||
|
||||
const commObj = {
|
||||
...commission,
|
||||
get_full_code: () => commission.code || `COM-${commission.id}`,
|
||||
subject: {
|
||||
get_full_code: () => commission.full_code || commission.code || `COM-${commission.id}`,
|
||||
subject: commission.subject || {
|
||||
name: commission.subject_name || 'Asignatura',
|
||||
code: commission.subject_code || 'COD-01',
|
||||
career_obj: { name: 'Licenciatura en Sistemas' }
|
||||
career_obj: { name: commission.career_name || 'Carrera General' }
|
||||
},
|
||||
teacher: commission.teacher_name ? { name: commission.teacher_name, email: 'docente@edu-space.com' } : null
|
||||
teacher: commission.teacher_name ? { name: commission.teacher_name, email: 'docente@edu-space.com' } : null,
|
||||
teachers: commission.teachers || (commission.teacher_name ? [{ name: commission.teacher_name, role: 'Titular', email: 'docente@edu-space.com' }] : [])
|
||||
};
|
||||
|
||||
let enrollments = [];
|
||||
try {
|
||||
const enrRes = await req.apiClient.get(`/commissions/${id}/enrollments`);
|
||||
if (enrRes.data && enrRes.data.enrollments) {
|
||||
enrollments = enrRes.data.enrollments;
|
||||
}
|
||||
} catch (enrErr) {
|
||||
console.warn('Commission enrollments API notice:', enrErr.message);
|
||||
}
|
||||
|
||||
let students = [];
|
||||
try {
|
||||
const usersRes = await req.apiClient.get('/users');
|
||||
const allUsers = Array.isArray(usersRes.data) ? usersRes.data : (usersRes.data.users || []);
|
||||
students = allUsers.filter(u => {
|
||||
const r = (u.role_name || u.role || '').toLowerCase();
|
||||
return r === 'alumno' || r === 'estudiante';
|
||||
});
|
||||
if (students.length === 0) {
|
||||
students = allUsers;
|
||||
}
|
||||
} catch (usersErr) {
|
||||
console.warn('Students list API notice:', usersErr.message);
|
||||
}
|
||||
|
||||
const enrolled_ids = enrollments.map(e => e.student_id);
|
||||
|
||||
res.render('admin/commissions/detail', {
|
||||
title: `Comisión ${commObj.get_full_code()} — Edu-Space`,
|
||||
commission: commObj,
|
||||
teachers,
|
||||
enrollments: [],
|
||||
can_edit: true
|
||||
students,
|
||||
enrollments,
|
||||
enrolled_ids,
|
||||
can_edit: true,
|
||||
error: req.query.error,
|
||||
success: req.query.success,
|
||||
unregistered: req.query.unregistered
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error in commission detail:', err.message);
|
||||
@@ -887,12 +980,87 @@ const handleCommissionAssignTeacher = async (req, res) => {
|
||||
res.redirect(`/admin/commission_detail?id=${id}`);
|
||||
};
|
||||
|
||||
const handleCommissionAddTeacher = async (req, res) => {
|
||||
const id = req.params.id || req.body.id || req.body.commission_id;
|
||||
const { teacher_id, role } = req.body;
|
||||
try {
|
||||
await req.apiClient.post(`/commissions/${id}/teachers`, {
|
||||
teacher_id: parseInt(teacher_id, 10),
|
||||
role: role || 'Titular'
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Commission add teacher API notice:', e.response?.data || e.message);
|
||||
}
|
||||
res.redirect(`/admin/commission_detail?id=${id}`);
|
||||
};
|
||||
|
||||
const handleCommissionRemoveTeacher = async (req, res) => {
|
||||
const id = req.params.id || req.body.id || req.body.commission_id;
|
||||
const teacher_id = req.params.teacher_id || req.body.teacher_id;
|
||||
try {
|
||||
await req.apiClient.delete(`/commissions/${id}/teachers/${teacher_id}`);
|
||||
} catch (e) {
|
||||
console.warn('Commission remove teacher API notice:', e.response?.data || e.message);
|
||||
}
|
||||
res.redirect(`/admin/commission_detail?id=${id}`);
|
||||
};
|
||||
|
||||
const handleCommissionEnrollStudent = async (req, res) => {
|
||||
const id = req.params.id || req.body.id || req.body.commission_id;
|
||||
const { student_id, notes, allow_same_day_exception, exception_reason } = req.body;
|
||||
try {
|
||||
await req.apiClient.post(`/commissions/${id}/enrollments`, {
|
||||
student_id: parseInt(student_id, 10),
|
||||
notes: (notes || '').trim() || null,
|
||||
allow_same_day_exception: Boolean(allow_same_day_exception),
|
||||
exception_reason: (exception_reason || '').trim() || null
|
||||
});
|
||||
res.redirect(`/admin/commission_detail?id=${id}&success=1`);
|
||||
} catch (e) {
|
||||
const errorMsg = encodeURIComponent(e.response?.data?.message || 'Error al matricular estudiante.');
|
||||
res.redirect(`/admin/commission_detail?id=${id}&error=${errorMsg}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommissionUnenrollStudent = async (req, res) => {
|
||||
const id = req.params.id || req.body.id || req.body.commission_id;
|
||||
const student_id = req.params.student_id || req.body.student_id;
|
||||
try {
|
||||
await req.apiClient.delete(`/commissions/${id}/enrollments/${student_id}`);
|
||||
res.redirect(`/admin/commission_detail?id=${id}&unregistered=1`);
|
||||
} catch (e) {
|
||||
const errorMsg = encodeURIComponent(e.response?.data?.message || 'Error al desvincular estudiante.');
|
||||
res.redirect(`/admin/commission_detail?id=${id}&error=${errorMsg}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommissionUpdateEnrollment = async (req, res) => {
|
||||
const id = req.params.id || req.body.id || req.body.commission_id;
|
||||
const student_id = req.params.student_id || req.body.student_id;
|
||||
const { status, notes } = req.body;
|
||||
try {
|
||||
await req.apiClient.put(`/commissions/${id}/enrollments/${student_id}`, {
|
||||
status: status || 'activo',
|
||||
notes: notes || undefined
|
||||
});
|
||||
res.redirect(`/admin/commission_detail?id=${id}&updated=1`);
|
||||
} catch (e) {
|
||||
const errorMsg = encodeURIComponent(e.response?.data?.message || 'Error al actualizar estado de matrícula.');
|
||||
res.redirect(`/admin/commission_detail?id=${id}&error=${errorMsg}`);
|
||||
}
|
||||
};
|
||||
|
||||
router.get(['/commissions_list', '/commissions', '/commission_list'], handleCommissionsList);
|
||||
router.get(['/commission_detail', '/commission_detail/:id', '/commissions/:id'], handleCommissionDetail);
|
||||
router.post(['/commission_add', '/commissions/add'], handleCommissionAdd);
|
||||
router.post(['/commission_edit', '/commission_edit/:id'], handleCommissionEdit);
|
||||
router.post(['/commission_toggle', '/commission_toggle/:id'], handleCommissionToggle);
|
||||
router.post(['/commission_assign_teacher', '/commission_assign_teacher/:id'], handleCommissionAssignTeacher);
|
||||
router.post(['/commission_add_teacher', '/commissions/:id/teachers', '/commissions/teachers/add'], handleCommissionAddTeacher);
|
||||
router.post(['/commission_remove_teacher', '/commissions/:id/teachers/delete', '/commissions/teachers/remove'], handleCommissionRemoveTeacher);
|
||||
router.post(['/commission_enroll_student', '/commissions/:id/enroll-student', '/commissions/enroll-student'], handleCommissionEnrollStudent);
|
||||
router.post(['/commission_unenroll_student', '/commissions/:id/unenroll-student', '/commissions/unenroll-student'], handleCommissionUnenrollStudent);
|
||||
router.post(['/commission_update_enrollment', '/commissions/:id/update-enrollment'], handleCommissionUpdateEnrollment);
|
||||
|
||||
// ─── 8. ACADEMIC TERMS ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user