fix: version 2.5.0 -- compartir proyectos con permisos reales (Lectura/Escritura)

Bug de seguridad real corregido: cualquier usuario autenticado veia
TODOS los proyectos (listado, detalle, actividades, tareas,
comentarios, adjuntos, KPIs del panel), sin importar si era miembro.
Ahora nadie ve un proyecto ajeno sin que su dueno se lo comparta --
los roles de administracion (superadmin/admin/it_manager) mantienen
visibilidad total para soporte.

Nuevo modelo de permisos por proyecto, simple y no granular:
- Solo el dueno original puede compartir un proyecto (agregar/quitar
  miembros, cambiar su nivel) -- ni un admin del proyecto ni un rol
  global pueden hacerlo por el.
- Dos niveles: Lectura (solo ver) y Escritura (crear/editar/eliminar
  actividades y tareas -- nunca el proyecto en si: no lo borra, no
  edita sus datos, no crea proyectos nuevos).

Actividades y tareas pasan a borrado logico (antes se borraban
fisico sin poder recuperar) -- nueva papelera por proyecto con
restaurar.

Incluye tambien el filtro "Usuario" en Proyectos (probado en dev en
un paso anterior), que va de la mano con este cambio de visibilidad.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-25 18:19:41 -05:00
parent 9b20f85ae0
commit fda83e9dff
30 changed files with 469 additions and 140 deletions
+11 -2
View File
@@ -2,7 +2,7 @@ import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
export const getActivitiesByProject = asyncHandler(async (req, res) => {
ok(res, await service.listByProject(Number(req.params.projectId)));
ok(res, await service.listByProject(Number(req.params.projectId), req.currentUser));
});
export const postActivity = asyncHandler(async (req, res) => {
@@ -14,7 +14,7 @@ export const putReorderActivities = asyncHandler(async (req, res) => {
});
export const getActivity = asyncHandler(async (req, res) => {
ok(res, await service.getActivity(Number(req.params.id)));
ok(res, await service.getActivity(Number(req.params.id), req.currentUser));
});
export const putActivity = asyncHandler(async (req, res) => {
@@ -26,6 +26,15 @@ export const deleteActivity = asyncHandler(async (req, res) => {
ok(res, { deleted: true });
});
export const postActivityRestore = asyncHandler(async (req, res) => {
await service.restoreActivity(Number(req.params.id), req.currentUser);
ok(res, { restored: true });
});
export const getActivitiesTrash = asyncHandler(async (req, res) => {
ok(res, await service.listTrash(Number(req.params.projectId), req.currentUser));
});
export const putMoveActivity = asyncHandler(async (req, res) => {
ok(res, await service.moveActivity(Number(req.params.id), Number(req.body.projectId), req.currentUser));
});
+18 -8
View File
@@ -5,7 +5,8 @@ const LIST_SELECT = `
a.priority_id, pr.name AS priority_name, pr.color AS priority_color,
a.status_id, st.name AS status_name, st.color AS status_color, st.is_closed AS status_is_closed,
a.start_date, a.due_date, a.actual_end_date, a.progress_pct, a.is_milestone,
a.estimated_hours, a.sort_order, a.created_by, a.created_at, a.updated_at
a.estimated_hours, a.sort_order, a.created_by, a.created_at, a.updated_at,
a.deleted_at, a.deleted_by
FROM activities a
JOIN priorities pr ON pr.id = a.priority_id
JOIN activity_statuses st ON st.id = a.status_id
@@ -13,12 +14,18 @@ const LIST_SELECT = `
`;
export async function findByProject(projectId) {
const [rows] = await pool.query(`${LIST_SELECT} WHERE a.project_id = ? ORDER BY a.sort_order, a.id`, [projectId]);
const [rows] = await pool.query(`${LIST_SELECT} WHERE a.project_id = ? AND a.deleted_at IS NULL ORDER BY a.sort_order, a.id`, [projectId]);
return rows;
}
export async function findById(id) {
const [rows] = await pool.query(`${LIST_SELECT} WHERE a.id = ?`, [id]);
export async function findTrashByProject(projectId) {
const [rows] = await pool.query(`${LIST_SELECT} WHERE a.project_id = ? AND a.deleted_at IS NOT NULL ORDER BY a.id DESC`, [projectId]);
return rows;
}
export async function findById(id, { includeDeleted = false } = {}) {
const where = includeDeleted ? 'a.id = ?' : 'a.id = ? AND a.deleted_at IS NULL';
const [rows] = await pool.query(`${LIST_SELECT} WHERE ${where}`, [id]);
return rows[0] || null;
}
@@ -59,9 +66,12 @@ export async function updateProgress(id, progressPct) {
await pool.query('UPDATE activities SET progress_pct = ? WHERE id = ?', [progressPct, id]);
}
// Arrastra activity_dependencies (ambos lados) y tasks por ON DELETE CASCADE.
export async function remove(id) {
await pool.query('DELETE FROM activities WHERE id = ?', [id]);
export async function softDelete(id, userId) {
await pool.query('UPDATE activities SET deleted_at = NOW(), deleted_by = ? WHERE id = ?', [userId, id]);
}
export async function restore(id) {
await pool.query('UPDATE activities SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', [id]);
}
// Las tareas (tasks.activity_id) y los comentarios/adjuntos (entity_id de la actividad) se
@@ -84,7 +94,7 @@ export async function reorder(projectId, ids) {
}
export async function countTasks(id) {
const [rows] = await pool.query('SELECT COUNT(*) AS c FROM tasks WHERE activity_id = ?', [id]);
const [rows] = await pool.query('SELECT COUNT(*) AS c FROM tasks WHERE activity_id = ? AND deleted_at IS NULL', [id]);
return rows[0].c;
}
+2 -1
View File
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { getActivity, putActivity, deleteActivity, putMoveActivity, postConvertToProject, postDependency, deleteDependency } from './controller.js';
import { getActivity, putActivity, deleteActivity, postActivityRestore, putMoveActivity, postConvertToProject, postDependency, deleteDependency } from './controller.js';
const router = Router();
@@ -8,6 +8,7 @@ router.use(requireAuth);
router.get('/:id', getActivity);
router.put('/:id', putActivity);
router.delete('/:id', deleteActivity);
router.post('/:id/restore', postActivityRestore);
router.put('/:id/move', putMoveActivity);
router.post('/:id/convert-to-project', postConvertToProject);
router.post('/:id/dependencies', postDependency);
+34 -15
View File
@@ -2,9 +2,8 @@ import * as repo from './repository.js';
import * as projectsRepo from '../projects/repository.js';
import * as commentsRepo from '../comments/repository.js';
import * as attachmentsRepo from '../attachments/repository.js';
import * as attachmentsService from '../attachments/service.js';
import * as tasksRepo from '../tasks/repository.js';
import { recalcProjectProgress, canWriteProject } from '../projects/service.js';
import { recalcProjectProgress, canReadProject, canWriteProject } from '../projects/service.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { ROLES } from '../../middleware/auth.js';
@@ -16,20 +15,31 @@ async function getProjectOr404(projectId) {
return project;
}
// admin/it_manager siempre; project_lead si tiene acceso al proyecto; collaborator solo si es
// el responsable de la actividad (no puede crear actividades nuevas ni reasignarlas a otros).
export async function canWriteActivity(user, project, activity) {
if ([ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(user.roleCode)) return true;
if (user.roleCode === ROLES.PROJECT_LEAD) return canWriteProject(user, project);
if (user.roleCode === ROLES.COLLABORATOR && activity) return activity.responsible_user_id === user.id;
return false;
// Se delega entero a canWriteProject: con el modelo de compartir por proyecto (Lectura/Escritura,
// ver projects/service.js), quien tiene Escritura puede crear/editar/eliminar cualquier
// actividad o tarea del proyecto -- ya no importa si es el "responsable" asignado o no (esa
// regla vieja, previa a tener niveles de permiso reales por proyecto, quedo obsoleta).
export async function canWriteActivity(user, project) {
return canWriteProject(user, project);
}
export const listByProject = (projectId) => repo.findByProject(projectId);
export async function listByProject(projectId, currentUser) {
const project = await getProjectOr404(projectId);
if (!(await canReadProject(currentUser, project))) throw new HttpError(404, 'Proyecto no encontrado');
return repo.findByProject(projectId);
}
export async function getActivity(id) {
export async function listTrash(projectId, currentUser) {
const project = await getProjectOr404(projectId);
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
return repo.findTrashByProject(projectId);
}
export async function getActivity(id, currentUser) {
const activity = await repo.findById(id);
if (!activity) throw new HttpError(404, 'Actividad no encontrada');
const project = await getProjectOr404(activity.project_id);
if (!(await canReadProject(currentUser, project))) throw new HttpError(404, 'Actividad no encontrada');
const dependencies = await repo.listDependencies(id);
return { ...activity, dependencies };
}
@@ -101,7 +111,8 @@ export async function updateActivity(id, payload, currentUser) {
// Bloquea el borrado si hay tareas pendientes (a proposito: evita perder de vista trabajo real
// hecho/por hacer solo porque alguien borro la actividad de un clic). El usuario debe vaciar el
// checklist primero. Comentarios/adjuntos si se limpian solos, no llevan informacion de progreso.
// checklist primero. Es eliminacion logica (recuperable, ver restoreActivity) -- comentarios y
// adjuntos NO se tocan, quedan intactos por si se restaura despues.
export async function removeActivity(id, currentUser) {
const activity = await repo.findById(id);
if (!activity) throw new HttpError(404, 'Actividad no encontrada');
@@ -113,13 +124,21 @@ export async function removeActivity(id, currentUser) {
if (taskCount > 0) {
throw new HttpError(400, `Esta actividad tiene ${taskCount} tarea(s). Elimina primero las tareas antes de borrar la actividad.`);
}
await commentsRepo.removeByEntity('activity', id);
await attachmentsService.removeAllForEntity('activity', id);
await repo.remove(id);
await repo.softDelete(id, currentUser.id);
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'activity', entityId: id });
await recalcProjectProgress(activity.project_id);
}
export async function restoreActivity(id, currentUser) {
const activity = await repo.findById(id, { includeDeleted: true });
if (!activity || !activity.deleted_at) throw new HttpError(404, 'Actividad no encontrada en la papelera');
const project = await getProjectOr404(activity.project_id);
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
await repo.restore(id);
await logAudit({ userId: currentUser.id, action: 'restore', entityType: 'activity', entityId: id });
await recalcProjectProgress(activity.project_id);
}
// Mueve la actividad (y sus tareas/comentarios/adjuntos, que van solos por referenciar activity_id
// en vez de project_id) a otro proyecto. Exige permiso de escritura en AMBOS proyectos.
export async function moveActivity(id, targetProjectId, currentUser) {
+15 -1
View File
@@ -4,14 +4,23 @@ import { ok, fail, asyncHandler, HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { ROLES } from '../../middleware/auth.js';
import { uploadsDir } from '../../middleware/upload.js';
import { resolveProjectForEntity, canReadProject, canWriteProject } from '../projects/service.js';
import * as repo from './repository.js';
import { removeAttachment } from './service.js';
const VALID_ENTITY_TYPES = new Set(['project', 'activity']);
async function getProjectOr404(entityType, entityId) {
const project = await resolveProjectForEntity(entityType, entityId);
if (!project) throw new HttpError(404, 'No encontrado');
return project;
}
export const getAttachments = asyncHandler(async (req, res) => {
const { entityType, entityId } = req.query;
if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) return fail(res, 400, 'entityType y entityId son obligatorios');
const project = await getProjectOr404(entityType, Number(entityId));
if (!(await canReadProject(req.currentUser, project))) throw new HttpError(404, 'No encontrado');
ok(res, await repo.findByEntity(entityType, Number(entityId)));
});
@@ -19,6 +28,8 @@ export const postAttachment = asyncHandler(async (req, res) => {
const { entityType, entityId } = req.body || {};
if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) throw new HttpError(400, 'entityType y entityId son obligatorios');
if (req.currentUser.roleCode === ROLES.VIEWER) throw new HttpError(403, 'Tu rol es de solo lectura');
const project = await getProjectOr404(entityType, Number(entityId));
if (!(await canWriteProject(req.currentUser, project))) throw new HttpError(403, 'No tienes permisos de escritura en este proyecto');
if (!req.file) throw new HttpError(400, 'Archivo obligatorio');
const attachment = await repo.create({
@@ -33,6 +44,8 @@ export const postAttachment = asyncHandler(async (req, res) => {
export const downloadAttachment = asyncHandler(async (req, res) => {
const attachment = await repo.findById(Number(req.params.id));
if (!attachment) throw new HttpError(404, 'Adjunto no encontrado');
const project = await getProjectOr404(attachment.entity_type, attachment.entity_id);
if (!(await canReadProject(req.currentUser, project))) throw new HttpError(404, 'Adjunto no encontrado');
const filePath = path.join(uploadsDir, attachment.filename);
if (!fs.existsSync(filePath)) throw new HttpError(404, 'Archivo no encontrado en el servidor');
res.download(filePath, attachment.original_name);
@@ -41,7 +54,8 @@ export const downloadAttachment = asyncHandler(async (req, res) => {
export const deleteAttachment = asyncHandler(async (req, res) => {
const attachment = await repo.findById(Number(req.params.id));
if (!attachment) throw new HttpError(404, 'Adjunto no encontrado');
const canDelete = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(req.currentUser.roleCode) || attachment.uploaded_by === req.currentUser.id;
const project = await getProjectOr404(attachment.entity_type, attachment.entity_id);
const canDelete = (await canWriteProject(req.currentUser, project)) || attachment.uploaded_by === req.currentUser.id;
if (!canDelete) throw new HttpError(403, 'No puedes eliminar este adjunto');
await removeAttachment(attachment);
@@ -2,13 +2,22 @@ import { ok, fail, asyncHandler } from '../../utils/http.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { ROLES } from '../../middleware/auth.js';
import { resolveProjectForEntity, canReadProject, canWriteProject } from '../projects/service.js';
import * as repo from './repository.js';
const VALID_ENTITY_TYPES = new Set(['project', 'activity']);
async function getProjectOr404(entityType, entityId) {
const project = await resolveProjectForEntity(entityType, entityId);
if (!project) throw new HttpError(404, 'No encontrado');
return project;
}
export const getComments = asyncHandler(async (req, res) => {
const { entityType, entityId } = req.query;
if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) return fail(res, 400, 'entityType y entityId son obligatorios');
const project = await getProjectOr404(entityType, Number(entityId));
if (!(await canReadProject(req.currentUser, project))) throw new HttpError(404, 'No encontrado');
ok(res, await repo.findByEntity(entityType, Number(entityId)));
});
@@ -18,6 +27,8 @@ export const postComment = asyncHandler(async (req, res) => {
throw new HttpError(400, 'entityType, entityId y message son obligatorios');
}
if (req.currentUser.roleCode === ROLES.VIEWER) throw new HttpError(403, 'Tu rol es de solo lectura');
const project = await getProjectOr404(entityType, Number(entityId));
if (!(await canWriteProject(req.currentUser, project))) throw new HttpError(403, 'No tienes permisos de escritura en este proyecto');
const comment = await repo.create({ entityType, entityId, userId: req.currentUser.id, message });
await logAudit({ userId: req.currentUser.id, action: 'comment', entityType, entityId, detail: { commentId: comment.id } });
ok(res, comment, 201);
+15 -5
View File
@@ -2,12 +2,22 @@ import { ok, fail, asyncHandler } from '../../utils/http.js';
import * as repo from './repository.js';
import { listByProject } from '../activities/service.js';
import { dueStatus } from '../../utils/dates.js';
import { ROLES } from '../../middleware/auth.js';
const FULL_ACCESS_ROLES = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER];
// Mismo criterio que projects/service.js#listProjects: los roles de administracion ven todo el
// panel; el resto solo cuenta/lista lo de proyectos donde es miembro.
function memberFilterFor(currentUser) {
return FULL_ACCESS_ROLES.includes(currentUser.roleCode) ? null : currentUser.id;
}
export const getSummary = asyncHandler(async (req, res) => {
const memberUserId = memberFilterFor(req.currentUser);
const [kpis, myActivities, byStatus] = await Promise.all([
repo.summary(),
repo.myPendingActivities(req.currentUser.id),
repo.projectsByStatus(),
repo.summary(memberUserId),
repo.myPendingActivities(req.currentUser.id, memberUserId),
repo.projectsByStatus(memberUserId),
]);
ok(res, {
kpis,
@@ -22,12 +32,12 @@ export const getCalendar = asyncHandler(async (req, res) => {
const start = `${month}-01`;
const [year, m] = month.split('-').map(Number);
const end = new Date(year, m, 0).toISOString().slice(0, 10);
const items = await repo.calendarActivities(start, end);
const items = await repo.calendarActivities(start, end, memberFilterFor(req.currentUser));
ok(res, items.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date, a.status_is_closed) })));
});
export const getGantt = asyncHandler(async (req, res) => {
const activities = await listByProject(Number(req.params.id));
const activities = await listByProject(Number(req.params.id), req.currentUser);
ok(res, activities.map((a) => ({
id: a.id, code: a.code, name: a.name, startDate: a.start_date, dueDate: a.due_date,
progressPct: a.progress_pct, isMilestone: !!a.is_milestone, responsibleName: a.responsible_name,
+37 -17
View File
@@ -1,38 +1,51 @@
import { pool } from '../../config/db.js';
export async function summary() {
// memberUserId: se pasa solo para roles sin visibilidad total (ver dashboard/controller.js),
// restringe cada KPI a proyectos donde el usuario es miembro -- mismo criterio que
// projects/repository.js#findAll, para no mostrar conteos de proyectos que ni siquiera puede ver.
export async function summary(memberUserId) {
const memberClause = memberUserId ? 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)' : '';
const memberParams = memberUserId ? [memberUserId] : [];
const [[activeProjects]] = await pool.query(
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id
WHERE ps.is_closed = 0 AND p.deleted_at IS NULL`
WHERE ps.is_closed = 0 AND p.deleted_at IS NULL ${memberClause}`,
memberParams
);
const [[overdueProjects]] = await pool.query(
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id
WHERE ps.is_closed = 0 AND p.deleted_at IS NULL
AND p.estimated_end_date IS NOT NULL AND p.estimated_end_date < CURDATE()`
AND p.estimated_end_date IS NOT NULL AND p.estimated_end_date < CURDATE() ${memberClause}`,
memberParams
);
const [[dueSoonProjects]] = await pool.query(
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id
WHERE ps.is_closed = 0 AND p.deleted_at IS NULL AND p.estimated_end_date IS NOT NULL
AND p.estimated_end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY)`
AND p.estimated_end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY) ${memberClause}`,
memberParams
);
const [[pendingActivities]] = await pool.query(
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id
JOIN projects p ON p.id = a.project_id
WHERE st.is_closed = 0 AND p.deleted_at IS NULL`
WHERE st.is_closed = 0 AND p.deleted_at IS NULL ${memberClause}`,
memberParams
);
const [[overdueActivities]] = await pool.query(
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id
JOIN projects p ON p.id = a.project_id
WHERE st.is_closed = 0 AND p.deleted_at IS NULL AND a.due_date IS NOT NULL AND a.due_date < CURDATE()`
WHERE st.is_closed = 0 AND p.deleted_at IS NULL AND a.due_date IS NOT NULL AND a.due_date < CURDATE() ${memberClause}`,
memberParams
);
const [[todayActivities]] = await pool.query(
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id
JOIN projects p ON p.id = a.project_id
WHERE st.is_closed = 0 AND p.deleted_at IS NULL AND a.due_date = CURDATE()`
WHERE st.is_closed = 0 AND p.deleted_at IS NULL AND a.due_date = CURDATE() ${memberClause}`,
memberParams
);
const [[avgProgress]] = await pool.query(
`SELECT AVG(progress_pct) AS avg_pct FROM projects p JOIN project_statuses ps ON ps.id = p.status_id
WHERE ps.is_closed = 0 AND p.deleted_at IS NULL`
WHERE ps.is_closed = 0 AND p.deleted_at IS NULL ${memberClause}`,
memberParams
);
return {
@@ -46,34 +59,41 @@ export async function summary() {
};
}
export async function myPendingActivities(userId) {
export async function myPendingActivities(userId, memberUserId) {
const memberClause = memberUserId ? 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)' : '';
const params = memberUserId ? [userId, memberUserId] : [userId];
const [rows] = await pool.query(
`SELECT a.id, a.code, a.name, a.due_date, a.progress_pct, p.id AS project_id, p.name AS project_name
FROM activities a JOIN activity_statuses st ON st.id = a.status_id JOIN projects p ON p.id = a.project_id
WHERE st.is_closed = 0 AND p.deleted_at IS NULL AND a.responsible_user_id = ?
WHERE st.is_closed = 0 AND p.deleted_at IS NULL AND a.responsible_user_id = ? ${memberClause}
ORDER BY (a.due_date IS NULL), a.due_date LIMIT 20`,
[userId]
params
);
return rows;
}
export async function calendarActivities(monthStart, monthEnd) {
export async function calendarActivities(monthStart, monthEnd, memberUserId) {
const memberClause = memberUserId ? 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)' : '';
const params = memberUserId ? [monthStart, monthEnd, memberUserId] : [monthStart, monthEnd];
const [rows] = await pool.query(
`SELECT a.id, a.code, a.name, a.due_date, a.is_milestone, a.progress_pct,
st.is_closed AS status_is_closed, p.id AS project_id, p.name AS project_name
FROM activities a JOIN activity_statuses st ON st.id = a.status_id JOIN projects p ON p.id = a.project_id
WHERE a.due_date BETWEEN ? AND ? AND p.deleted_at IS NULL
WHERE a.due_date BETWEEN ? AND ? AND p.deleted_at IS NULL ${memberClause}
ORDER BY a.due_date`,
[monthStart, monthEnd]
params
);
return rows;
}
export async function projectsByStatus() {
export async function projectsByStatus(memberUserId) {
const memberClause = memberUserId ? 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)' : '';
const params = memberUserId ? [memberUserId] : [];
const [rows] = await pool.query(
`SELECT ps.name, ps.color, COUNT(p.id) AS total
FROM project_statuses ps LEFT JOIN projects p ON p.status_id = ps.id AND p.deleted_at IS NULL
GROUP BY ps.id ORDER BY ps.sort_order`
FROM project_statuses ps LEFT JOIN projects p ON p.status_id = ps.id AND p.deleted_at IS NULL ${memberClause}
GROUP BY ps.id ORDER BY ps.sort_order`,
params
);
return rows;
}
+2 -2
View File
@@ -7,7 +7,7 @@ export const getProjects = asyncHandler(async (req, res) => {
});
export const getProject = asyncHandler(async (req, res) => {
ok(res, await service.getProject(Number(req.params.id)));
ok(res, await service.getProject(Number(req.params.id), req.currentUser));
});
export const postProject = asyncHandler(async (req, res) => {
@@ -45,7 +45,7 @@ export const postMember = asyncHandler(async (req, res) => {
});
export const putMemberRole = asyncHandler(async (req, res) => {
const members = await service.setMemberAdmin(Number(req.params.id), Number(req.params.userId), !!req.body.admin, req.currentUser);
const members = await service.setMemberRole(Number(req.params.id), Number(req.params.userId), req.body.role, req.currentUser);
ok(res, members);
});
+7 -1
View File
@@ -18,12 +18,18 @@ const LIST_SELECT = `
LEFT JOIN departments sd ON sd.id = p.responsible_department_id
`;
export async function findAll({ statusId, priorityId, ownerUserId, trash } = {}) {
export async function findAll({ statusId, priorityId, ownerUserId, memberUserId, trash } = {}) {
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); }
// 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) {
clauses.push('EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)');
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;
+2 -1
View File
@@ -5,7 +5,7 @@ import {
deleteProjectPermanent, postMember, putMemberRole, deleteMember,
} from './controller.js';
import { getGantt } from '../dashboard/controller.js';
import { getActivitiesByProject, postActivity, putReorderActivities } from '../activities/controller.js';
import { getActivitiesByProject, getActivitiesTrash, postActivity, putReorderActivities } from '../activities/controller.js';
const router = Router();
@@ -22,6 +22,7 @@ router.post('/:id/members', postMember);
router.put('/:id/members/:userId/role', putMemberRole);
router.delete('/:id/members/:userId', deleteMember);
router.get('/:projectId/activities', getActivitiesByProject);
router.get('/:projectId/activities/trash', getActivitiesTrash);
router.post('/:projectId/activities', postActivity);
router.put('/:projectId/activities/reorder', putReorderActivities);
+80 -21
View File
@@ -1,35 +1,83 @@
import * as repo from './repository.js';
import * as activitiesRepo from '../activities/repository.js';
import * as usersService from '../users/service.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { ROLES } from '../../middleware/auth.js';
// Alcance de escritura: admin/superadmin/it_manager siempre; un proyecto puede tener uno o mas
// "administradores del proyecto" (role_in_project = 'owner'/'admin' en project_members) con acceso
// total sin importar su rol global — un collaborator puede ser admin de UN proyecto puntual;
// ademas, por compatibilidad, cualquier project_lead que sea miembro (con o sin ese tag) tambien
// puede escribir, igual que antes de introducir el rol de administrador por proyecto.
export async function canWriteProject(user, project) {
if ([ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(user.roleCode)) return true;
const FULL_ACCESS_ROLES = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER];
// Nadie ve ni toca el proyecto de otro dueño sin que se lo compartan -- salvo los roles
// globales de administracion, que siguen viendo todo para poder dar soporte (acordado con el
// usuario). "Compartido" = tiene una fila en project_members, sin importar el nivel.
export async function canReadProject(user, project) {
if (FULL_ACCESS_ROLES.includes(user.roleCode)) return true;
const membership = await repo.getMembership(project.id, user.id);
if (membership === 'owner' || membership === 'admin') return true;
return membership !== null;
}
// Niveles simples, no granulares (acordado): Lectura (role_in_project NULL) o Escritura
// (role_in_project = 'write') -- crear/editar/eliminar(logico) actividades y tareas, nunca el
// proyecto en si. 'owner' es quien lo creo. 'admin' es un valor legado (de cuando existia un
// 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 (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;
if (user.roleCode === ROLES.PROJECT_LEAD && membership !== null) return true;
return false;
}
export function isProjectOwner(user, project) {
return project.owner_user_id === user.id;
}
// Compartir (agregar/quitar miembros, cambiar su nivel) es exclusivo del dueño original -- ni
// 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);
}
// 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);
}
// Solo el superadmin puede ver la papelera (proyectos eliminados logicamente).
export async function listProjects(filters, currentUser) {
if (filters.trash && currentUser?.roleCode !== ROLES.SUPERADMIN) {
throw new HttpError(403, 'Solo el superadministrador puede ver la papelera');
}
return repo.findAll(filters);
if (filters.trash || FULL_ACCESS_ROLES.includes(currentUser?.roleCode)) {
return repo.findAll(filters);
}
// El resto de roles solo ve proyectos donde es miembro (compartidos con el, o propios).
return repo.findAll({ ...filters, memberUserId: currentUser.id });
}
export async function getProject(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.
export async function resolveProjectForEntity(entityType, entityId) {
if (entityType === 'project') return repo.findById(entityId);
if (entityType === 'activity') {
const activity = await activitiesRepo.findById(entityId, { includeDeleted: true });
if (!activity) return null;
return repo.findById(activity.project_id);
}
return null;
}
export async function getProject(id, currentUser) {
const project = await repo.findById(id);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canReadProject(currentUser, project))) throw new HttpError(404, 'Proyecto no encontrado');
const members = await repo.listMembers(id);
return { ...project, members };
return { ...project, members, isOwner: isProjectOwner(currentUser, project) };
}
export async function createProject(payload, currentUser) {
@@ -48,10 +96,13 @@ export async function createProject(payload, currentUser) {
return project;
}
// Editar los datos del PROYECTO en si (nombre, fechas, prioridad, etc.) es exclusivo del dueño o
// de un rol global de administracion -- un colaborador compartido con Escritura solo puede
// crear/editar actividades y tareas, nunca el proyecto (acordado con el usuario).
export async function updateProject(id, payload, currentUser) {
const project = await repo.findById(id);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
if (!canDeleteProject(currentUser, project)) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
if (!payload.name || !payload.priorityId || !payload.statusId) {
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
}
@@ -60,12 +111,12 @@ export async function updateProject(id, payload, currentUser) {
return updated;
}
// Eliminacion logica: cualquiera que pueda editar el proyecto puede "eliminarlo" (deja de verse
// en el listado, pero sigue en la BD). Solo el superadmin puede restaurarlo o borrarlo definitivo.
// Eliminacion logica: solo el dueño o un rol global de administracion. Solo el superadmin puede
// restaurarlo o borrarlo definitivo (sin cambios).
export async function deleteProject(id, currentUser) {
const project = await repo.findById(id);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
if (!canDeleteProject(currentUser, project)) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
await repo.softDelete(id, currentUser.id);
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'project', entityId: id });
}
@@ -99,10 +150,17 @@ export async function recalcProjectProgress(projectId) {
// crea una cuenta 'invited' y le manda un correo para que complete su propio registro (nombre y
// clave) — la fila de project_members ya queda creada de una vez, asi que en cuanto acepte la
// invitacion ya aparece como miembro sin pasos adicionales.
// role_in_project que llega del cliente: solo 'write' o null/undefined (Lectura) son validos
// para asignar de ahora en mas -- 'owner'/'admin' son valores que el propio backend controla.
function normalizeRoleInProject(role) {
return role === 'write' ? 'write' : null;
}
export async function addMember(projectId, { userId, email }, roleInProject, currentUser) {
const project = await repo.findById(projectId);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
if (!canManageMembers(currentUser, project)) throw new HttpError(403, 'Solo el dueño del proyecto puede compartirlo');
roleInProject = normalizeRoleInProject(roleInProject);
let targetUserId = userId;
let invited = false;
@@ -121,13 +179,14 @@ export async function addMember(projectId, { userId, email }, roleInProject, cur
return { members, invited };
}
export async function setMemberAdmin(projectId, userId, isAdmin, currentUser) {
export async function setMemberRole(projectId, userId, roleInProject, currentUser) {
const project = await repo.findById(projectId);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
const members = await repo.updateMemberRole(projectId, userId, isAdmin ? 'admin' : null);
if (!canManageMembers(currentUser, project)) throw new HttpError(403, 'Solo el dueño del proyecto puede cambiar permisos');
roleInProject = normalizeRoleInProject(roleInProject);
const members = await repo.updateMemberRole(projectId, userId, roleInProject);
await logAudit({
userId: currentUser.id, action: isAdmin ? 'grant_project_admin' : 'revoke_project_admin',
userId: currentUser.id, action: roleInProject === 'write' ? 'grant_project_write' : 'revoke_project_write',
entityType: 'project', entityId: projectId, detail: { userId },
});
return members;
@@ -136,7 +195,7 @@ export async function setMemberAdmin(projectId, userId, isAdmin, currentUser) {
export async function removeMember(projectId, userId, currentUser) {
const project = await repo.findById(projectId);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
if (!canManageMembers(currentUser, project)) throw new HttpError(403, 'Solo el dueño del proyecto puede quitar miembros');
const members = await repo.removeMember(projectId, userId);
await logAudit({ userId: currentUser.id, action: 'remove_member', entityType: 'project', entityId: projectId, detail: { userId } });
return members;
+10 -1
View File
@@ -2,7 +2,16 @@ import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
export const getTasks = asyncHandler(async (req, res) => {
ok(res, await service.listByActivity(Number(req.params.activityId)));
ok(res, await service.listByActivity(Number(req.params.activityId), req.currentUser));
});
export const getTasksTrash = asyncHandler(async (req, res) => {
ok(res, await service.listTrash(Number(req.params.activityId), req.currentUser));
});
export const postTaskRestore = asyncHandler(async (req, res) => {
await service.restoreTask(Number(req.params.id), req.currentUser);
ok(res, { restored: true });
});
export const postTask = asyncHandler(async (req, res) => {
+25 -9
View File
@@ -1,11 +1,22 @@
import { pool } from '../../config/db.js';
const LIST_SELECT = `
SELECT t.id, t.activity_id, t.name, t.responsible_user_id, u.name AS responsible_name,
t.is_done, t.due_date, t.sort_order, t.created_at
FROM tasks t LEFT JOIN users u ON u.id = t.responsible_user_id
`;
export async function findByActivity(activityId) {
const [rows] = await pool.query(
`SELECT t.id, t.activity_id, t.name, t.responsible_user_id, u.name AS responsible_name,
t.is_done, t.due_date, t.sort_order, t.created_at
FROM tasks t LEFT JOIN users u ON u.id = t.responsible_user_id
WHERE t.activity_id = ? ORDER BY t.sort_order, t.created_at`,
`${LIST_SELECT} WHERE t.activity_id = ? AND t.deleted_at IS NULL ORDER BY t.sort_order, t.created_at`,
[activityId]
);
return rows;
}
export async function findTrashByActivity(activityId) {
const [rows] = await pool.query(
`${LIST_SELECT} WHERE t.activity_id = ? AND t.deleted_at IS NOT NULL ORDER BY t.id DESC`,
[activityId]
);
return rows;
@@ -19,8 +30,9 @@ export async function reorder(activityId, ids) {
}
}
export async function findById(id) {
const [rows] = await pool.query('SELECT * FROM tasks WHERE id = ?', [id]);
export async function findById(id, { includeDeleted = false } = {}) {
const where = includeDeleted ? 'id = ?' : 'id = ? AND deleted_at IS NULL';
const [rows] = await pool.query(`SELECT * FROM tasks WHERE ${where}`, [id]);
return rows[0] || null;
}
@@ -44,8 +56,12 @@ export async function setDone(id, isDone) {
return findById(id);
}
export async function remove(id) {
await pool.query('DELETE FROM tasks WHERE id = ?', [id]);
export async function softDelete(id, userId) {
await pool.query('UPDATE tasks SET deleted_at = NOW(), deleted_by = ? WHERE id = ?', [userId, id]);
}
export async function restore(id) {
await pool.query('UPDATE tasks SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', [id]);
}
// Usado al convertir una actividad en proyecto: sus tareas pasan a la actividad nueva que se crea
@@ -56,7 +72,7 @@ export async function moveAll(oldActivityId, newActivityId) {
export async function countsByActivity(activityId) {
const [rows] = await pool.query(
'SELECT COUNT(*) AS total, SUM(is_done) AS done FROM tasks WHERE activity_id = ?',
'SELECT COUNT(*) AS total, SUM(is_done) AS done FROM tasks WHERE activity_id = ? AND deleted_at IS NULL',
[activityId]
);
return { total: rows[0].total, done: Number(rows[0].done) || 0 };
+3 -1
View File
@@ -1,15 +1,17 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { getTasks, postTask, putTask, putReorderTasks, patchTaskDone, deleteTask } from './controller.js';
import { getTasks, getTasksTrash, postTask, putTask, putReorderTasks, patchTaskDone, deleteTask, postTaskRestore } from './controller.js';
const router = Router();
router.use(requireAuth);
router.get('/activities/:activityId/tasks', getTasks);
router.get('/activities/:activityId/tasks/trash', getTasksTrash);
router.post('/activities/:activityId/tasks', postTask);
router.put('/activities/:activityId/tasks/reorder', putReorderTasks);
router.put('/tasks/:id', putTask);
router.patch('/tasks/:id/done', patchTaskDone);
router.delete('/tasks/:id', deleteTask);
router.post('/tasks/:id/restore', postTaskRestore);
export default router;
+24 -2
View File
@@ -2,6 +2,7 @@ import * as repo from './repository.js';
import * as activitiesRepo from '../activities/repository.js';
import * as projectsRepo from '../projects/repository.js';
import { canWriteActivity, recalcFromTasks } from '../activities/service.js';
import { canReadProject } from '../projects/service.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
@@ -15,7 +16,18 @@ async function requireActivityAccess(activityId, currentUser) {
return activity;
}
export const listByActivity = (activityId) => repo.findByActivity(activityId);
export async function listByActivity(activityId, currentUser) {
const activity = await activitiesRepo.findById(activityId);
if (!activity) throw new HttpError(404, 'Actividad no encontrada');
const project = await projectsRepo.findById(activity.project_id);
if (!(await canReadProject(currentUser, project))) throw new HttpError(404, 'Actividad no encontrada');
return repo.findByActivity(activityId);
}
export async function listTrash(activityId, currentUser) {
await requireActivityAccess(activityId, currentUser);
return repo.findTrashByActivity(activityId);
}
export async function createTask(activityId, payload, currentUser) {
await requireActivityAccess(activityId, currentUser);
@@ -60,8 +72,18 @@ export async function removeTask(id, currentUser) {
const task = await repo.findById(id);
if (!task) throw new HttpError(404, 'Tarea no encontrada');
await requireActivityAccess(task.activity_id, currentUser);
await repo.remove(id);
await repo.softDelete(id, currentUser.id);
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'task', entityId: id });
const counts = await repo.countsByActivity(task.activity_id);
await recalcFromTasks(task.activity_id, counts.done, counts.total);
}
export async function restoreTask(id, currentUser) {
const task = await repo.findById(id, { includeDeleted: true });
if (!task || !task.deleted_at) throw new HttpError(404, 'Tarea no encontrada en la papelera');
await requireActivityAccess(task.activity_id, currentUser);
await repo.restore(id);
await logAudit({ userId: currentUser.id, action: 'restore', entityType: 'task', entityId: id });
const counts = await repo.countsByActivity(task.activity_id);
await recalcFromTasks(task.activity_id, counts.done, counts.total);
}