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 &&