feat: version 3.0.0 -- Correcciones de auditoria de seguridad, accesibilidad y redundancia
Permisos (superadmin, viewer, busqueda, tareas recurrentes), bootstrap atomico, revocacion de JWT por version de sesion (028), control optimista de edicion (029), logo sin SVG + CSP, ingesta endurecida, correos escapados, borradores, paginacion de proyectos, accesibilidad de dialogos/controles e i18n. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,10 +2,12 @@ import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as service from './service.js';
|
||||
|
||||
export const getProjects = asyncHandler(async (req, res) => {
|
||||
const { statusId, priorityId, ownerUserId, trash } = req.query;
|
||||
ok(res, await service.listProjects({ statusId, priorityId, ownerUserId, trash: trash === 'true' }, req.currentUser));
|
||||
const { statusId, priorityId, ownerUserId, trash, page, pageSize, dueSoon, progress, q } = req.query;
|
||||
ok(res, await service.listProjects({ statusId, priorityId, ownerUserId, trash: trash === 'true', page, pageSize, dueSoon: dueSoon === 'true', progress, q }, req.currentUser));
|
||||
});
|
||||
|
||||
export const getProjectOptions = asyncHandler(async (req, res) => ok(res, await service.listProjectOptions(req.currentUser)));
|
||||
|
||||
export const getProject = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.getProject(Number(req.params.id), req.currentUser));
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ const LIST_SELECT = `
|
||||
p.requesting_department_id, rd.name AS requesting_department_name,
|
||||
p.responsible_department_id, sd.name AS responsible_department_name,
|
||||
p.start_date, p.estimated_end_date, p.actual_end_date, p.progress_pct,
|
||||
p.risk_level, p.notes, p.created_by, p.created_at, p.updated_at, p.deleted_at, p.deleted_by,
|
||||
p.risk_level, p.notes, p.edit_version, p.created_by, p.created_at, p.updated_at, p.deleted_at, p.deleted_by,
|
||||
(SELECT COUNT(*) FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id <> p.owner_user_id) AS shared_count,
|
||||
(SELECT COUNT(*) FROM project_members pm2 WHERE pm2.project_id = p.id) AS members_count,
|
||||
(SELECT COUNT(*) FROM activities act WHERE act.project_id = p.id AND act.deleted_at IS NULL) AS activities_total,
|
||||
@@ -38,12 +38,16 @@ function castCounts(row) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function findAll({ statusId, priorityId, ownerUserId, memberUserId, trash } = {}) {
|
||||
export async function findAll({ statusId, priorityId, ownerUserId, memberUserId, trash, page = 1, pageSize = 20, dueSoon, progress, q } = {}) {
|
||||
const clauses = [trash ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL'];
|
||||
const params = [];
|
||||
if (statusId) { clauses.push('p.status_id = ?'); params.push(statusId); }
|
||||
if (priorityId) { clauses.push('p.priority_id = ?'); params.push(priorityId); }
|
||||
if (ownerUserId) { clauses.push('p.owner_user_id = ?'); params.push(ownerUserId); }
|
||||
if (dueSoon) clauses.push('ps.is_closed = 0 AND p.estimated_end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY)');
|
||||
if (progress === 'hide') clauses.push('p.progress_pct < 100');
|
||||
if (progress === 'only') clauses.push('p.progress_pct >= 100');
|
||||
if (q) { clauses.push('(p.code LIKE ? OR p.name LIKE ? OR u.name LIKE ?)'); params.push(...Array(3).fill(`%${q}%`)); }
|
||||
// Restringe a proyectos donde el usuario es miembro (compartidos con el, o el propio dueño ya
|
||||
// que quien crea un proyecto queda como miembro 'owner' automaticamente -- ver create()).
|
||||
if (memberUserId) {
|
||||
@@ -51,8 +55,18 @@ export async function findAll({ statusId, priorityId, ownerUserId, memberUserId,
|
||||
params.push(memberUserId);
|
||||
}
|
||||
const where = `WHERE ${clauses.join(' AND ')}`;
|
||||
const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.created_at DESC`, params);
|
||||
return rows.map(castCounts);
|
||||
const [[{ total }]] = await pool.query(`SELECT COUNT(*) AS total FROM projects p JOIN project_statuses ps ON ps.id = p.status_id JOIN users u ON u.id = p.owner_user_id ${where}`, params);
|
||||
const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.updated_at DESC, p.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize]);
|
||||
return { items: rows.map(castCounts), total: Number(total), page, pageSize };
|
||||
}
|
||||
|
||||
export async function findOptions(memberUserId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT p.id, p.code, p.name FROM projects p WHERE p.deleted_at IS NULL
|
||||
${memberUserId ? 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)' : ''}
|
||||
ORDER BY p.name`, memberUserId ? [memberUserId] : []
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function findById(id, { includeDeleted = false } = {}) {
|
||||
@@ -62,11 +76,11 @@ export async function findById(id, { includeDeleted = false } = {}) {
|
||||
}
|
||||
|
||||
export async function softDelete(id, userId) {
|
||||
await pool.query('UPDATE projects SET deleted_at = NOW(), deleted_by = ? WHERE id = ? AND deleted_at IS NULL', [userId, id]);
|
||||
await pool.query('UPDATE projects SET deleted_at = NOW(), deleted_by = ?, edit_version = edit_version + 1 WHERE id = ? AND deleted_at IS NULL', [userId, id]);
|
||||
}
|
||||
|
||||
export async function restore(id) {
|
||||
await pool.query('UPDATE projects SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', [id]);
|
||||
await pool.query('UPDATE projects SET deleted_at = NULL, deleted_by = NULL, edit_version = edit_version + 1 WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
export async function hardDelete(id) {
|
||||
@@ -106,21 +120,21 @@ export async function getDefaultStatusId() {
|
||||
}
|
||||
|
||||
export async function update(id, data) {
|
||||
await pool.query(
|
||||
const [result] = await pool.query(
|
||||
`UPDATE projects SET
|
||||
name = ?, description = ?, objective = ?, project_type_id = ?, priority_id = ?, status_id = ?,
|
||||
owner_user_id = ?, requesting_department_id = ?, responsible_department_id = ?,
|
||||
start_date = ?, estimated_end_date = ?, actual_end_date = ?, risk_level = ?, notes = ?
|
||||
WHERE id = ?`,
|
||||
start_date = ?, estimated_end_date = ?, actual_end_date = ?, risk_level = ?, notes = ?, edit_version = edit_version + 1
|
||||
WHERE id = ? AND edit_version = ? AND deleted_at IS NULL`,
|
||||
[
|
||||
data.name, data.description || null, data.objective || null, data.projectTypeId || null,
|
||||
data.priorityId, data.statusId, data.ownerUserId,
|
||||
data.requestingDepartmentId || null, data.responsibleDepartmentId || null,
|
||||
data.startDate || null, data.estimatedEndDate || null, data.actualEndDate || null,
|
||||
data.riskLevel || 'low', data.notes || null, id,
|
||||
data.riskLevel || 'low', data.notes || null, id, data.editVersion,
|
||||
]
|
||||
);
|
||||
return findById(id);
|
||||
return result.affectedRows ? findById(id) : null;
|
||||
}
|
||||
|
||||
export async function updateProgress(id, progressPct) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import {
|
||||
getProjects, getProject, postProject, putProject, deleteProject, postProjectRestore,
|
||||
getProjects, getProjectOptions, getProject, postProject, putProject, deleteProject, postProjectRestore,
|
||||
deleteProjectPermanent, postMember, putMemberRole, deleteMember,
|
||||
} from './controller.js';
|
||||
import { getGantt } from '../dashboard/controller.js';
|
||||
@@ -11,6 +11,7 @@ const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/', getProjects);
|
||||
router.get('/options', getProjectOptions);
|
||||
router.post('/', postProject);
|
||||
router.get('/:id', getProject);
|
||||
router.put('/:id', putProject);
|
||||
|
||||
@@ -22,6 +22,7 @@ export async function canReadProject(user, project) {
|
||||
// tercer nivel "administrador de proyecto") que se sigue respetando como escritura en filas
|
||||
// viejas, pero ya no se asigna desde la UI.
|
||||
export async function canWriteProject(user, project) {
|
||||
if (user.roleCode === ROLES.VIEWER) return false;
|
||||
if (FULL_ACCESS_ROLES.includes(user.roleCode)) return true;
|
||||
const membership = await repo.getMembership(project.id, user.id);
|
||||
if (['owner', 'admin', 'write'].includes(membership)) return true;
|
||||
@@ -37,18 +38,22 @@ export function isProjectOwner(user, project) {
|
||||
// un admin del proyecto ni un rol global de administracion pueden compartir el proyecto de otra
|
||||
// persona sin que el dueño lo autorice (acordado con el usuario).
|
||||
export function canManageMembers(user, project) {
|
||||
return isProjectOwner(user, project);
|
||||
return user.roleCode !== ROLES.VIEWER && isProjectOwner(user, project);
|
||||
}
|
||||
|
||||
// Borrar el PROYECTO (no su contenido) es exclusivo del dueño o de un rol global de
|
||||
// administracion -- un colaborador con permiso de Escritura puede borrar actividades/tareas
|
||||
// dentro del proyecto, pero nunca el proyecto en si (acordado con el usuario).
|
||||
export function canDeleteProject(user, project) {
|
||||
return FULL_ACCESS_ROLES.includes(user.roleCode) || isProjectOwner(user, project);
|
||||
return user.roleCode !== ROLES.VIEWER && (FULL_ACCESS_ROLES.includes(user.roleCode) || isProjectOwner(user, project));
|
||||
}
|
||||
|
||||
// Solo el superadmin puede ver la papelera (proyectos eliminados logicamente).
|
||||
export async function listProjects(filters, currentUser) {
|
||||
const page = Number(filters.page || 1);
|
||||
const pageSize = Number(filters.pageSize || 20);
|
||||
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100 || !Number.isSafeInteger((page - 1) * pageSize)) throw new HttpError(400, 'Paginacion invalida');
|
||||
filters = { ...filters, page, pageSize, q: String(filters.q || '').trim().slice(0, 100) };
|
||||
if (filters.trash && currentUser?.roleCode !== ROLES.SUPERADMIN) {
|
||||
throw new HttpError(403, 'Solo el superadministrador puede ver la papelera');
|
||||
}
|
||||
@@ -59,6 +64,10 @@ export async function listProjects(filters, currentUser) {
|
||||
return repo.findAll({ ...filters, memberUserId: currentUser.id });
|
||||
}
|
||||
|
||||
export function listProjectOptions(currentUser) {
|
||||
return repo.findOptions(FULL_ACCESS_ROLES.includes(currentUser.roleCode) ? null : currentUser.id);
|
||||
}
|
||||
|
||||
// Usado por comments/attachments, que cuelgan de 'project' o 'activity' (entity_type/entity_id
|
||||
// generico, ver BASE_DE_DATOS.md) -- resuelve a que proyecto pertenece la entidad para poder
|
||||
// aplicar el mismo chequeo de lectura/escritura por membresia en los dos modulos.
|
||||
@@ -106,7 +115,9 @@ export async function updateProject(id, payload, currentUser) {
|
||||
if (!payload.name || !payload.priorityId || !payload.statusId) {
|
||||
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
|
||||
}
|
||||
if (!Number.isSafeInteger(payload.editVersion) || payload.editVersion < 1) throw new HttpError(428, 'Falta la version del proyecto');
|
||||
const updated = await repo.update(id, { ...payload, ownerUserId: payload.ownerUserId || project.owner_user_id });
|
||||
if (!updated) throw new HttpError(409, 'El proyecto cambio en el servidor. Recarga antes de guardar');
|
||||
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'project', entityId: id });
|
||||
return updated;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user