feat: completar Fase 2 del Roadmap MVP (Co-docencia, Matriz de Conflictos y Regla Diaria) y auditoria de seguridad

This commit is contained in:
2026-09-19 14:05:16 -03:00
parent 05e197e49f
commit ada39248d7
36 changed files with 3037 additions and 443 deletions
+203 -35
View File
@@ -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 ────────────────────────────────────────────────────────