diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 5a6f393..e768791 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -97,11 +97,20 @@ "eliminarlo" — se oculta de listados/dashboard, sigue en BD). Borrado físico (`DELETE`, arrastra actividades/comentarios/adjuntos por `ON DELETE CASCADE`) solo vía `permanentlyDeleteProject`, solo superadmin, y solo sobre un proyecto que ya tiene `deleted_at` (dos pasos, nunca directo). +- `activities.deleted_at`/`deleted_by` y `tasks.deleted_at`/`deleted_by` (desde v2.5.0): antes se + borraban físico sin poder recuperar; ahora es lógica igual que `projects` — quien tiene permiso + de Escritura en el proyecto puede eliminarlas y restaurarlas (`POST .../restore`), sin borrado + físico ni endpoint de purga definitiva (no hizo falta, a diferencia de `projects`). - `project_members.role_in_project`: `'owner'` (dueño, se asigna solo al crear el proyecto, nunca - se toca desde el endpoint de rol), `'admin'` (administrador **de ese proyecto puntual** — un - proyecto puede tener varios, otorgado por quien ya puede escribir el proyecto, sin importar el - rol global de a quién se lo otorgan), `NULL`/otro = miembro sin permisos de escritura (salvo que - además sea `project_lead` global, comportamiento previo sin cambios). + se toca desde el endpoint de rol), `'write'` (permiso de Escritura: crear/editar/eliminar + (lógico) actividades y tareas del proyecto — nunca el proyecto en sí), `NULL` = Lectura (solo + ver). **Compartir un proyecto (agregar/quitar miembros, cambiar su nivel) es exclusivo del + dueño** (`projects/service.js#canManageMembers`) — ni un rol global de administración puede + hacerlo por él. `'admin'` es un valor legado (de un tercer nivel "administrador de proyecto" + que ya no existe) que se sigue respetando como Escritura en filas viejas, pero no se asigna + desde la UI. Los roles superadmin/admin/it_manager ven, editan y pueden borrar (lógico) + cualquier proyecto igual que antes (soporte/supervisión) — la única acción que queda exclusiva + del dueño, sin excepción ni para ellos, es compartir/dejar de compartir el proyecto. - Empresa asumida única (no hay tabla `companies` en el MVP). - Producción siempre usa `192.168.222.32`. Desarrollo siempre usa `192.168.222.20`. No mezclar. - `error_logs`: sin límite de tiempo, pero se recorta solo a los 1000 registros más recientes en diff --git a/CHANGELOG.md b/CHANGELOG.md index d2df3e8..fb954cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog - project.linkedcorp.net +## [2.5.0] - 2026-09-25 +### Fixed +- **Bug de seguridad real**: cualquier usuario autenticado veía TODOS los proyectos del sistema + (listado, detalle, actividades, tareas, comentarios y adjuntos), sin importar si era miembro. + Ahora nadie ve un proyecto ajeno a menos que su dueño se lo comparta — los roles de + administración (superadmin/admin/it_manager) siguen viendo todo, para poder dar soporte. +### Added +- **Compartir proyectos**: solo el dueño del proyecto puede compartirlo (ni un admin del + proyecto ni un rol global pueden hacerlo por él). Dos niveles simples: **Lectura** (solo ver) + y **Escritura** (crear/editar/eliminar actividades y tareas del proyecto — nunca el proyecto + en sí: no puede borrarlo, ni editar sus datos, ni crear proyectos nuevos). +- **Papelera de actividades**: eliminar una actividad o tarea ahora es recuperable (antes se + borraba físico, sin vuelta atrás) — el dueño del proyecto puede restaurarlas desde un nuevo + botón "Papelera de actividades" en la pestaña Actividades. +- Panel principal (KPIs, calendario, "mis actividades pendientes"): mismo criterio de + visibilidad que el listado de proyectos — ya no cuenta ni muestra información de proyectos + ajenos para roles sin acceso total. +- Filtro "Usuario" en Proyectos (junto a Estado/Prioridad), visible solo para + superadmin/admin/it_manager — filtra el listado por dueño. Se había probado solo en dev; se + incluye en esta versión al ir de la mano con el resto de este cambio. + ## [2.4.0] - 2026-09-22 ### Fixed - Menú lateral en celular: antes quedaba siempre visible fijo, ocupando gran parte de una diff --git a/VERSION b/VERSION index 197c4d5..437459c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.4.0 +2.5.0 diff --git a/backend/database/migrations/016_activities_tasks_soft_delete.sql b/backend/database/migrations/016_activities_tasks_soft_delete.sql new file mode 100644 index 0000000..e4ea05d --- /dev/null +++ b/backend/database/migrations/016_activities_tasks_soft_delete.sql @@ -0,0 +1,12 @@ +-- Eliminacion logica de actividades y tareas (antes se borraban fisico, sin poder recuperar). +-- Mismo patron que projects (003_project_soft_delete.sql). Idempotente. + +ALTER TABLE activities + ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL, + ADD COLUMN IF NOT EXISTS deleted_by INT NULL; +CREATE INDEX IF NOT EXISTS idx_activities_deleted_at ON activities(deleted_at); + +ALTER TABLE tasks + ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL, + ADD COLUMN IF NOT EXISTS deleted_by INT NULL; +CREATE INDEX IF NOT EXISTS idx_tasks_deleted_at ON tasks(deleted_at); diff --git a/backend/package.json b/backend/package.json index 53fab50..0f9b0d0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.4.0", + "version": "2.5.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/activities/controller.js b/backend/src/modules/activities/controller.js index 357e3b8..318c430 100644 --- a/backend/src/modules/activities/controller.js +++ b/backend/src/modules/activities/controller.js @@ -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)); }); diff --git a/backend/src/modules/activities/repository.js b/backend/src/modules/activities/repository.js index 784c261..762d418 100644 --- a/backend/src/modules/activities/repository.js +++ b/backend/src/modules/activities/repository.js @@ -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; } diff --git a/backend/src/modules/activities/routes.js b/backend/src/modules/activities/routes.js index b050d5d..232ab73 100644 --- a/backend/src/modules/activities/routes.js +++ b/backend/src/modules/activities/routes.js @@ -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); diff --git a/backend/src/modules/activities/service.js b/backend/src/modules/activities/service.js index 374caa4..3407e78 100644 --- a/backend/src/modules/activities/service.js +++ b/backend/src/modules/activities/service.js @@ -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) { diff --git a/backend/src/modules/attachments/controller.js b/backend/src/modules/attachments/controller.js index 3692d7f..1ce5ea3 100644 --- a/backend/src/modules/attachments/controller.js +++ b/backend/src/modules/attachments/controller.js @@ -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); diff --git a/backend/src/modules/comments/controller.js b/backend/src/modules/comments/controller.js index b1ca3a4..d05f03a 100644 --- a/backend/src/modules/comments/controller.js +++ b/backend/src/modules/comments/controller.js @@ -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); diff --git a/backend/src/modules/dashboard/controller.js b/backend/src/modules/dashboard/controller.js index f24dceb..b217caa 100644 --- a/backend/src/modules/dashboard/controller.js +++ b/backend/src/modules/dashboard/controller.js @@ -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, diff --git a/backend/src/modules/dashboard/repository.js b/backend/src/modules/dashboard/repository.js index 04e1b3f..06c44dd 100644 --- a/backend/src/modules/dashboard/repository.js +++ b/backend/src/modules/dashboard/repository.js @@ -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; } diff --git a/backend/src/modules/projects/controller.js b/backend/src/modules/projects/controller.js index 67914b2..410a2e5 100644 --- a/backend/src/modules/projects/controller.js +++ b/backend/src/modules/projects/controller.js @@ -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); }); diff --git a/backend/src/modules/projects/repository.js b/backend/src/modules/projects/repository.js index 6bf3c81..ff39b24 100644 --- a/backend/src/modules/projects/repository.js +++ b/backend/src/modules/projects/repository.js @@ -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; diff --git a/backend/src/modules/projects/routes.js b/backend/src/modules/projects/routes.js index 1df4411..da68209 100644 --- a/backend/src/modules/projects/routes.js +++ b/backend/src/modules/projects/routes.js @@ -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); diff --git a/backend/src/modules/projects/service.js b/backend/src/modules/projects/service.js index 16cb131..72eeb53 100644 --- a/backend/src/modules/projects/service.js +++ b/backend/src/modules/projects/service.js @@ -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; diff --git a/backend/src/modules/tasks/controller.js b/backend/src/modules/tasks/controller.js index b3c0c5f..f36636c 100644 --- a/backend/src/modules/tasks/controller.js +++ b/backend/src/modules/tasks/controller.js @@ -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) => { diff --git a/backend/src/modules/tasks/repository.js b/backend/src/modules/tasks/repository.js index ce43614..953fd29 100644 --- a/backend/src/modules/tasks/repository.js +++ b/backend/src/modules/tasks/repository.js @@ -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 }; diff --git a/backend/src/modules/tasks/routes.js b/backend/src/modules/tasks/routes.js index 5a4c768..d5e206c 100644 --- a/backend/src/modules/tasks/routes.js +++ b/backend/src/modules/tasks/routes.js @@ -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; diff --git a/backend/src/modules/tasks/service.js b/backend/src/modules/tasks/service.js index 7c21644..3f62e43 100644 --- a/backend/src/modules/tasks/service.js +++ b/backend/src/modules/tasks/service.js @@ -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); +} diff --git a/frontend/package.json b/frontend/package.json index c3b6267..e98a9eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.4.0", + "version": "2.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 8361a67..516f2a7 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -168,12 +168,12 @@ export const messages = { project_risk: 'Riesgo', project_notes: 'Observaciones', project_members: 'Miembros', - project_add_member: 'Agregar miembro', - project_add_member_as_admin: 'Agregar como administrador del proyecto', + project_members_owner_only_hint: 'Solo el dueño del proyecto puede compartirlo y cambiar permisos.', + project_add_member: 'Compartir proyecto', + project_add_member_role: 'Permiso', project_role_owner: 'Dueño', - project_role_admin: 'Administrador', - project_make_admin: 'Hacer administrador', - project_remove_admin: 'Quitar administrador', + project_role_read: 'Lectura', + project_role_write: 'Escritura', project_invite_email: 'Invitar por correo', project_invite_email_hint: 'Si el correo no pertenece a nadie todavia, se le manda un enlace para que complete su registro y se una al proyecto.', project_invite_sent: 'Invitacion enviada por correo', @@ -215,6 +215,11 @@ export const messages = { project_trash_title: 'Papelera de proyectos', project_trash_back: 'Volver a proyectos', project_trash_empty_hint: 'Los proyectos eliminados aparecen aqui hasta que se restauran o se borran definitivamente.', + common_restore: 'Restaurar', + activities_trash: 'Papelera de actividades', + activities_trash_empty: 'No hay actividades eliminadas.', + tasks_trash: 'Papelera de tareas', + tasks_trash_empty: 'No hay tareas eliminadas.', project_restore: 'Restaurar', project_delete_permanent: 'Eliminar definitivamente', project_delete_permanent_confirm_msg: 'Esta accion NO se puede deshacer: se borra el proyecto y todas sus actividades, comentarios y archivos adjuntos de forma permanente.', @@ -461,12 +466,12 @@ export const messages = { project_risk: 'Risk', project_notes: 'Notes', project_members: 'Members', - project_add_member: 'Add member', - project_add_member_as_admin: 'Add as project administrator', + project_members_owner_only_hint: 'Only the project owner can share it and change permissions.', + project_add_member: 'Share project', + project_add_member_role: 'Permission', project_role_owner: 'Owner', - project_role_admin: 'Administrator', - project_make_admin: 'Make administrator', - project_remove_admin: 'Remove administrator', + project_role_read: 'Read', + project_role_write: 'Write', project_invite_email: 'Invite by email', project_invite_email_hint: "If the email doesn't belong to anyone yet, they get a link to finish signing up and join the project.", project_invite_sent: 'Invite sent by email', @@ -508,6 +513,11 @@ export const messages = { project_trash_title: 'Project trash', project_trash_back: 'Back to projects', project_trash_empty_hint: 'Deleted projects show up here until they are restored or permanently deleted.', + common_restore: 'Restore', + activities_trash: 'Deleted activities', + activities_trash_empty: 'No deleted activities.', + tasks_trash: 'Deleted tasks', + tasks_trash_empty: 'No deleted tasks.', project_restore: 'Restore', project_delete_permanent: 'Delete permanently', project_delete_permanent_confirm_msg: 'This CANNOT be undone: the project and all its activities, comments and attachments will be permanently deleted.', diff --git a/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx b/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx index 07fbe5a..14fafec 100644 --- a/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx +++ b/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx @@ -1,11 +1,11 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Plus, Save, Trash2, ArrowRightLeft, FolderUp } from 'lucide-react'; +import { Plus, Save, Trash2, ArrowRightLeft, FolderUp, Archive, RotateCcw } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; import { useAuth } from '../../context/auth-context.jsx'; import { useAsync, useAction } from '../../hooks/useAsync.js'; import { useDebouncedValue } from '../../hooks/useDebouncedValue.js'; -import { listActivitiesByProject, createActivity, getActivity, deleteActivity, reorderActivities, moveActivity, convertActivityToProject } from '../../services/activities.js'; +import { listActivitiesByProject, createActivity, getActivity, deleteActivity, reorderActivities, moveActivity, convertActivityToProject, listActivitiesTrash, restoreActivity } from '../../services/activities.js'; import { listProjects } from '../../services/projects.js'; import { search as searchApi } from '../../services/search.js'; import { listCatalog } from '../../services/catalogs.js'; @@ -23,12 +23,25 @@ import ActivityDetailPanel from './ActivityDetailPanel.jsx'; const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false }; -export default function ProjectActivitiesTab({ projectId, openActivityId, openActivityTab }) { +export default function ProjectActivitiesTab({ project, projectId, openActivityId, openActivityTab }) { const { t, pushToast, openDialog } = useUi(); const { user: currentUser } = useAuth(); const navigate = useNavigate(); const runAction = useAction(); const { data: activities, reload } = useAsync(() => listActivitiesByProject(projectId), [projectId]); + const [trashOpen, setTrashOpen] = useState(false); + const { data: trashed, reload: reloadTrash } = useAsync( + () => (trashOpen ? listActivitiesTrash(projectId) : Promise.resolve([])), + [trashOpen, projectId] + ); + function restoreFromTrash(activity) { + runAction(async () => { + await restoreActivity(activity.id); + await reloadTrash(); + reload(); + pushToast({ tone: 'success', message: t('common_saved') }); + }); + } const { data: priorities } = useAsync(() => listCatalog('priorities'), []); const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []); const { data: users } = useAsync(listUsers, []); @@ -213,9 +226,22 @@ export default function ProjectActivitiesTab({ projectId, openActivityId, openAc )} + {project?.isOwner && ( + + )} + setTrashOpen(false)} width={480} height={420}> + {(trashed || []).length === 0 &&
{t('activities_trash_empty')}
} + {(trashed || []).map((a) => ( +
+ {a.code} — {a.name} + +
+ ))} +
+ !project.members.some((m) => m.user_id === u.id)).map((u) => ({ value: u.id, label: u.name })); const canAdd = !!addingMember || !!inviteEmail.trim(); + const roleOptions = [{ value: 'read', label: t('project_role_read') }, { value: 'write', label: t('project_role_write') }]; async function addMember() { if (!canAdd) return; @@ -31,19 +41,19 @@ export default function ProjectMembersTab({ project, onChanged }) { const target = inviteEmail.trim() ? { email: inviteEmail.trim() } : { userId: Number(addingMember) }; - const result = await addProjectMember(project.id, target, addAsAdmin ? 'admin' : null); + const result = await addProjectMember(project.id, target, newMemberRole === 'write' ? 'write' : null); setAddingMember(''); setInviteEmail(''); - setAddAsAdmin(false); + setNewMemberRole('read'); setAddMemberOpen(false); onChanged(); pushToast({ tone: 'success', message: result.invited ? t('project_invite_sent') : t('common_saved') }); }); } - function toggleAdmin(member) { + function changeRole(member, role) { runAction(async () => { - await setProjectMemberAdmin(project.id, member.user_id, member.role_in_project !== 'admin'); + await setProjectMemberRole(project.id, member.user_id, role === 'write' ? 'write' : null); onChanged(); }); } @@ -65,31 +75,35 @@ export default function ProjectMembersTab({ project, onChanged }) {

{t('project_members')}

- + {canManage && }
+ {!canManage &&
{t('project_members_owner_only_hint')}
} {project.members.map((m) => { - const badge = ROLE_BADGE[m.role_in_project]; + const badge = ROLE_BADGE[m.role_in_project] || { color: 'muted', key: 'project_role_read' }; return (
{m.name} · {m.email} - {badge && {t(badge.key)}} - {m.role_in_project !== 'owner' && ( - + {canManage && m.role_in_project !== 'owner' ? ( +
+ changeRole(m, v)} + /> +
+ ) : ( + {t(badge.key)} + )} + {canManage && m.role_in_project !== 'owner' && ( + )} -
); })} {project.members.length === 0 &&
{t('common_empty')}
}
- setAddMemberOpen(false)} width={420} height={380}> + setAddMemberOpen(false)} width={420} height={420}> - + + +
diff --git a/frontend/src/pages/Proyectos/ProyectoDetallePage.jsx b/frontend/src/pages/Proyectos/ProyectoDetallePage.jsx index cb13066..8bcae8f 100644 --- a/frontend/src/pages/Proyectos/ProyectoDetallePage.jsx +++ b/frontend/src/pages/Proyectos/ProyectoDetallePage.jsx @@ -58,7 +58,7 @@ export default function ProyectoDetallePage() { {tab === 'info' && } {tab === 'members' && } {tab === 'activities' && ( - + )} {tab === 'gantt' && } {tab === 'calendar' && } diff --git a/frontend/src/pages/Proyectos/ProyectosPage.jsx b/frontend/src/pages/Proyectos/ProyectosPage.jsx index 43457e4..ebcebaa 100644 --- a/frontend/src/pages/Proyectos/ProyectosPage.jsx +++ b/frontend/src/pages/Proyectos/ProyectosPage.jsx @@ -6,6 +6,7 @@ import { useAuth } from '../../context/auth-context.jsx'; import { useAsync, useAction } from '../../hooks/useAsync.js'; import { useDebouncedValue } from '../../hooks/useDebouncedValue.js'; import { listProjects, createProject, deleteProject, restoreProject, permanentlyDeleteProject } from '../../services/projects.js'; +import { listUsers } from '../../services/users.js'; import { search as searchApi } from '../../services/search.js'; import { listCatalog } from '../../services/catalogs.js'; import { listDepartments } from '../../services/departments.js'; @@ -46,11 +47,16 @@ export default function ProyectosPage() { const [filterStatusId, setFilterStatusId] = useState(''); const [filterPriorityId, setFilterPriorityId] = useState(''); const [filterDueSoon, setFilterDueSoon] = useState(false); + const [filterOwnerUserId, setFilterOwnerUserId] = useState(''); + // Prueba exploratoria: filtro "ver todos / por usuario" solo para roles que ya ven todos los + // proyectos hoy (superadmin/admin/it_manager) -- backend ya soporta ownerUserId, sin cambios. + const canFilterByUser = ['superadmin', 'admin', 'it_manager'].includes(currentUser?.roleCode); const { data: projects, reload } = useAsync( - () => listProjects(showTrash ? { trash: true } : { statusId: filterStatusId, priorityId: filterPriorityId }), - [showTrash, filterStatusId, filterPriorityId] + () => listProjects(showTrash ? { trash: true } : { statusId: filterStatusId, priorityId: filterPriorityId, ownerUserId: filterOwnerUserId || undefined }), + [showTrash, filterStatusId, filterPriorityId, filterOwnerUserId] ); + const { data: allUsers } = useAsync(listUsers, []); const { data: priorities } = useAsync(() => listCatalog('priorities'), []); const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []); const { data: types } = useAsync(() => listCatalog('project-types'), []); @@ -207,12 +213,31 @@ export default function ProyectosPage() {
{!showTrash && ( <> -
- +
+ {t('common_status')} +
+ +
-
- +
+ {t('common_priority')} +
+ +
+ {canFilterByUser && ( +
+ Usuario +
+ ({ value: u.id, label: u.name }))]} + value={filterOwnerUserId} + onChange={setFilterOwnerUserId} + placeholder="Usuario" + /> +
+
+ )}