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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-25 18:19:41 -05:00
parent 9b20f85ae0
commit fda83e9dff
30 changed files with 469 additions and 140 deletions
+13 -4
View File
@@ -97,11 +97,20 @@
"eliminarlo" — se oculta de listados/dashboard, sigue en BD). Borrado físico (`DELETE`, arrastra "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`, 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). 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 - `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 se toca desde el endpoint de rol), `'write'` (permiso de Escritura: crear/editar/eliminar
proyecto puede tener varios, otorgado por quien ya puede escribir el proyecto, sin importar el (lógico) actividades y tareas del proyecto — nunca el proyecto en sí), `NULL` = Lectura (solo
rol global de a quién se lo otorgan), `NULL`/otro = miembro sin permisos de escritura (salvo que ver). **Compartir un proyecto (agregar/quitar miembros, cambiar su nivel) es exclusivo del
además sea `project_lead` global, comportamiento previo sin cambios). 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). - 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. - 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 - `error_logs`: sin límite de tiempo, pero se recorta solo a los 1000 registros más recientes en
+21
View File
@@ -1,5 +1,26 @@
# Changelog - project.linkedcorp.net # 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 ## [2.4.0] - 2026-09-22
### Fixed ### Fixed
- Menú lateral en celular: antes quedaba siempre visible fijo, ocupando gran parte de una - Menú lateral en celular: antes quedaba siempre visible fijo, ocupando gran parte de una
+1 -1
View File
@@ -1 +1 @@
2.4.0 2.5.0
@@ -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);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "2.4.0", "version": "2.5.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+11 -2
View File
@@ -2,7 +2,7 @@ import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js'; import * as service from './service.js';
export const getActivitiesByProject = asyncHandler(async (req, res) => { 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) => { 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) => { 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) => { export const putActivity = asyncHandler(async (req, res) => {
@@ -26,6 +26,15 @@ export const deleteActivity = asyncHandler(async (req, res) => {
ok(res, { deleted: true }); 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) => { export const putMoveActivity = asyncHandler(async (req, res) => {
ok(res, await service.moveActivity(Number(req.params.id), Number(req.body.projectId), req.currentUser)); ok(res, await service.moveActivity(Number(req.params.id), Number(req.body.projectId), req.currentUser));
}); });
+18 -8
View File
@@ -5,7 +5,8 @@ const LIST_SELECT = `
a.priority_id, pr.name AS priority_name, pr.color AS priority_color, a.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.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.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 FROM activities a
JOIN priorities pr ON pr.id = a.priority_id JOIN priorities pr ON pr.id = a.priority_id
JOIN activity_statuses st ON st.id = a.status_id JOIN activity_statuses st ON st.id = a.status_id
@@ -13,12 +14,18 @@ const LIST_SELECT = `
`; `;
export async function findByProject(projectId) { 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; return rows;
} }
export async function findById(id) { export async function findTrashByProject(projectId) {
const [rows] = await pool.query(`${LIST_SELECT} WHERE a.id = ?`, [id]); 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; 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]); 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 softDelete(id, userId) {
export async function remove(id) { await pool.query('UPDATE activities SET deleted_at = NOW(), deleted_by = ? WHERE id = ?', [userId, id]);
await pool.query('DELETE FROM activities WHERE id = ?', [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 // 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) { 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; return rows[0].c;
} }
+2 -1
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js'; 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(); const router = Router();
@@ -8,6 +8,7 @@ router.use(requireAuth);
router.get('/:id', getActivity); router.get('/:id', getActivity);
router.put('/:id', putActivity); router.put('/:id', putActivity);
router.delete('/:id', deleteActivity); router.delete('/:id', deleteActivity);
router.post('/:id/restore', postActivityRestore);
router.put('/:id/move', putMoveActivity); router.put('/:id/move', putMoveActivity);
router.post('/:id/convert-to-project', postConvertToProject); router.post('/:id/convert-to-project', postConvertToProject);
router.post('/:id/dependencies', postDependency); router.post('/:id/dependencies', postDependency);
+34 -15
View File
@@ -2,9 +2,8 @@ import * as repo from './repository.js';
import * as projectsRepo from '../projects/repository.js'; import * as projectsRepo from '../projects/repository.js';
import * as commentsRepo from '../comments/repository.js'; import * as commentsRepo from '../comments/repository.js';
import * as attachmentsRepo from '../attachments/repository.js'; import * as attachmentsRepo from '../attachments/repository.js';
import * as attachmentsService from '../attachments/service.js';
import * as tasksRepo from '../tasks/repository.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 { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js'; import { logAudit } from '../../utils/audit.js';
import { ROLES } from '../../middleware/auth.js'; import { ROLES } from '../../middleware/auth.js';
@@ -16,20 +15,31 @@ async function getProjectOr404(projectId) {
return project; return project;
} }
// admin/it_manager siempre; project_lead si tiene acceso al proyecto; collaborator solo si es // Se delega entero a canWriteProject: con el modelo de compartir por proyecto (Lectura/Escritura,
// el responsable de la actividad (no puede crear actividades nuevas ni reasignarlas a otros). // ver projects/service.js), quien tiene Escritura puede crear/editar/eliminar cualquier
export async function canWriteActivity(user, project, activity) { // actividad o tarea del proyecto -- ya no importa si es el "responsable" asignado o no (esa
if ([ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(user.roleCode)) return true; // regla vieja, previa a tener niveles de permiso reales por proyecto, quedo obsoleta).
if (user.roleCode === ROLES.PROJECT_LEAD) return canWriteProject(user, project); export async function canWriteActivity(user, project) {
if (user.roleCode === ROLES.COLLABORATOR && activity) return activity.responsible_user_id === user.id; return canWriteProject(user, project);
return false;
} }
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); const activity = await repo.findById(id);
if (!activity) throw new HttpError(404, 'Actividad no encontrada'); 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); const dependencies = await repo.listDependencies(id);
return { ...activity, dependencies }; 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 // 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 // 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) { export async function removeActivity(id, currentUser) {
const activity = await repo.findById(id); const activity = await repo.findById(id);
if (!activity) throw new HttpError(404, 'Actividad no encontrada'); if (!activity) throw new HttpError(404, 'Actividad no encontrada');
@@ -113,13 +124,21 @@ export async function removeActivity(id, currentUser) {
if (taskCount > 0) { if (taskCount > 0) {
throw new HttpError(400, `Esta actividad tiene ${taskCount} tarea(s). Elimina primero las tareas antes de borrar la actividad.`); 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 repo.softDelete(id, currentUser.id);
await attachmentsService.removeAllForEntity('activity', id);
await repo.remove(id);
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'activity', entityId: id }); await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'activity', entityId: id });
await recalcProjectProgress(activity.project_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 // 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. // en vez de project_id) a otro proyecto. Exige permiso de escritura en AMBOS proyectos.
export async function moveActivity(id, targetProjectId, currentUser) { export async function moveActivity(id, targetProjectId, currentUser) {
+15 -1
View File
@@ -4,14 +4,23 @@ import { ok, fail, asyncHandler, HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js'; import { logAudit } from '../../utils/audit.js';
import { ROLES } from '../../middleware/auth.js'; import { ROLES } from '../../middleware/auth.js';
import { uploadsDir } from '../../middleware/upload.js'; import { uploadsDir } from '../../middleware/upload.js';
import { resolveProjectForEntity, canReadProject, canWriteProject } from '../projects/service.js';
import * as repo from './repository.js'; import * as repo from './repository.js';
import { removeAttachment } from './service.js'; import { removeAttachment } from './service.js';
const VALID_ENTITY_TYPES = new Set(['project', 'activity']); 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) => { export const getAttachments = asyncHandler(async (req, res) => {
const { entityType, entityId } = req.query; const { entityType, entityId } = req.query;
if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) return fail(res, 400, 'entityType y entityId son obligatorios'); 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))); ok(res, await repo.findByEntity(entityType, Number(entityId)));
}); });
@@ -19,6 +28,8 @@ export const postAttachment = asyncHandler(async (req, res) => {
const { entityType, entityId } = req.body || {}; const { entityType, entityId } = req.body || {};
if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) throw new HttpError(400, 'entityType y entityId son obligatorios'); 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'); 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'); if (!req.file) throw new HttpError(400, 'Archivo obligatorio');
const attachment = await repo.create({ const attachment = await repo.create({
@@ -33,6 +44,8 @@ export const postAttachment = asyncHandler(async (req, res) => {
export const downloadAttachment = asyncHandler(async (req, res) => { export const downloadAttachment = asyncHandler(async (req, res) => {
const attachment = await repo.findById(Number(req.params.id)); const attachment = await repo.findById(Number(req.params.id));
if (!attachment) throw new HttpError(404, 'Adjunto no encontrado'); 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); const filePath = path.join(uploadsDir, attachment.filename);
if (!fs.existsSync(filePath)) throw new HttpError(404, 'Archivo no encontrado en el servidor'); if (!fs.existsSync(filePath)) throw new HttpError(404, 'Archivo no encontrado en el servidor');
res.download(filePath, attachment.original_name); res.download(filePath, attachment.original_name);
@@ -41,7 +54,8 @@ export const downloadAttachment = asyncHandler(async (req, res) => {
export const deleteAttachment = asyncHandler(async (req, res) => { export const deleteAttachment = asyncHandler(async (req, res) => {
const attachment = await repo.findById(Number(req.params.id)); const attachment = await repo.findById(Number(req.params.id));
if (!attachment) throw new HttpError(404, 'Adjunto no encontrado'); 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'); if (!canDelete) throw new HttpError(403, 'No puedes eliminar este adjunto');
await removeAttachment(attachment); await removeAttachment(attachment);
@@ -2,13 +2,22 @@ import { ok, fail, asyncHandler } from '../../utils/http.js';
import { HttpError } from '../../utils/http.js'; import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js'; import { logAudit } from '../../utils/audit.js';
import { ROLES } from '../../middleware/auth.js'; import { ROLES } from '../../middleware/auth.js';
import { resolveProjectForEntity, canReadProject, canWriteProject } from '../projects/service.js';
import * as repo from './repository.js'; import * as repo from './repository.js';
const VALID_ENTITY_TYPES = new Set(['project', 'activity']); 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) => { export const getComments = asyncHandler(async (req, res) => {
const { entityType, entityId } = req.query; const { entityType, entityId } = req.query;
if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) return fail(res, 400, 'entityType y entityId son obligatorios'); 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))); 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'); 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'); 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 }); 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 } }); await logAudit({ userId: req.currentUser.id, action: 'comment', entityType, entityId, detail: { commentId: comment.id } });
ok(res, comment, 201); ok(res, comment, 201);
+15 -5
View File
@@ -2,12 +2,22 @@ import { ok, fail, asyncHandler } from '../../utils/http.js';
import * as repo from './repository.js'; import * as repo from './repository.js';
import { listByProject } from '../activities/service.js'; import { listByProject } from '../activities/service.js';
import { dueStatus } from '../../utils/dates.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) => { export const getSummary = asyncHandler(async (req, res) => {
const memberUserId = memberFilterFor(req.currentUser);
const [kpis, myActivities, byStatus] = await Promise.all([ const [kpis, myActivities, byStatus] = await Promise.all([
repo.summary(), repo.summary(memberUserId),
repo.myPendingActivities(req.currentUser.id), repo.myPendingActivities(req.currentUser.id, memberUserId),
repo.projectsByStatus(), repo.projectsByStatus(memberUserId),
]); ]);
ok(res, { ok(res, {
kpis, kpis,
@@ -22,12 +32,12 @@ export const getCalendar = asyncHandler(async (req, res) => {
const start = `${month}-01`; const start = `${month}-01`;
const [year, m] = month.split('-').map(Number); const [year, m] = month.split('-').map(Number);
const end = new Date(year, m, 0).toISOString().slice(0, 10); 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) }))); ok(res, items.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date, a.status_is_closed) })));
}); });
export const getGantt = asyncHandler(async (req, res) => { 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) => ({ ok(res, activities.map((a) => ({
id: a.id, code: a.code, name: a.name, startDate: a.start_date, dueDate: a.due_date, 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, progressPct: a.progress_pct, isMilestone: !!a.is_milestone, responsibleName: a.responsible_name,
+37 -17
View File
@@ -1,38 +1,51 @@
import { pool } from '../../config/db.js'; 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( const [[activeProjects]] = await pool.query(
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id `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( const [[overdueProjects]] = await pool.query(
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id `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
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( const [[dueSoonProjects]] = await pool.query(
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id `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 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( const [[pendingActivities]] = await pool.query(
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id `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 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( const [[overdueActivities]] = await pool.query(
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id `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 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( const [[todayActivities]] = await pool.query(
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id `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 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( 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 `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 { 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( 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 `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 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`, ORDER BY (a.due_date IS NULL), a.due_date LIMIT 20`,
[userId] params
); );
return rows; 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( const [rows] = await pool.query(
`SELECT a.id, a.code, a.name, a.due_date, a.is_milestone, a.progress_pct, `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 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 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`, ORDER BY a.due_date`,
[monthStart, monthEnd] params
); );
return rows; 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( const [rows] = await pool.query(
`SELECT ps.name, ps.color, COUNT(p.id) AS total `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 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` GROUP BY ps.id ORDER BY ps.sort_order`,
params
); );
return rows; return rows;
} }
+2 -2
View File
@@ -7,7 +7,7 @@ export const getProjects = asyncHandler(async (req, res) => {
}); });
export const getProject = 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) => { 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) => { 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); ok(res, members);
}); });
+7 -1
View File
@@ -18,12 +18,18 @@ const LIST_SELECT = `
LEFT JOIN departments sd ON sd.id = p.responsible_department_id 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 clauses = [trash ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL'];
const params = []; const params = [];
if (statusId) { clauses.push('p.status_id = ?'); params.push(statusId); } if (statusId) { clauses.push('p.status_id = ?'); params.push(statusId); }
if (priorityId) { clauses.push('p.priority_id = ?'); params.push(priorityId); } if (priorityId) { clauses.push('p.priority_id = ?'); params.push(priorityId); }
if (ownerUserId) { clauses.push('p.owner_user_id = ?'); params.push(ownerUserId); } 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 where = `WHERE ${clauses.join(' AND ')}`;
const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.created_at DESC`, params); const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.created_at DESC`, params);
return rows; return rows;
+2 -1
View File
@@ -5,7 +5,7 @@ import {
deleteProjectPermanent, postMember, putMemberRole, deleteMember, deleteProjectPermanent, postMember, putMemberRole, deleteMember,
} from './controller.js'; } from './controller.js';
import { getGantt } from '../dashboard/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(); const router = Router();
@@ -22,6 +22,7 @@ router.post('/:id/members', postMember);
router.put('/:id/members/:userId/role', putMemberRole); router.put('/:id/members/:userId/role', putMemberRole);
router.delete('/:id/members/:userId', deleteMember); router.delete('/:id/members/:userId', deleteMember);
router.get('/:projectId/activities', getActivitiesByProject); router.get('/:projectId/activities', getActivitiesByProject);
router.get('/:projectId/activities/trash', getActivitiesTrash);
router.post('/:projectId/activities', postActivity); router.post('/:projectId/activities', postActivity);
router.put('/:projectId/activities/reorder', putReorderActivities); router.put('/:projectId/activities/reorder', putReorderActivities);
+79 -20
View File
@@ -1,35 +1,83 @@
import * as repo from './repository.js'; import * as repo from './repository.js';
import * as activitiesRepo from '../activities/repository.js';
import * as usersService from '../users/service.js'; import * as usersService from '../users/service.js';
import { HttpError } from '../../utils/http.js'; import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js'; import { logAudit } from '../../utils/audit.js';
import { ROLES } from '../../middleware/auth.js'; import { ROLES } from '../../middleware/auth.js';
// Alcance de escritura: admin/superadmin/it_manager siempre; un proyecto puede tener uno o mas const FULL_ACCESS_ROLES = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER];
// "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; // Nadie ve ni toca el proyecto de otro dueño sin que se lo compartan -- salvo los roles
// ademas, por compatibilidad, cualquier project_lead que sea miembro (con o sin ese tag) tambien // globales de administracion, que siguen viendo todo para poder dar soporte (acordado con el
// puede escribir, igual que antes de introducir el rol de administrador por proyecto. // usuario). "Compartido" = tiene una fila en project_members, sin importar el nivel.
export async function canWriteProject(user, project) { export async function canReadProject(user, project) {
if ([ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(user.roleCode)) return true; if (FULL_ACCESS_ROLES.includes(user.roleCode)) return true;
const membership = await repo.getMembership(project.id, user.id); 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; if (user.roleCode === ROLES.PROJECT_LEAD && membership !== null) return true;
return false; 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). // Solo el superadmin puede ver la papelera (proyectos eliminados logicamente).
export async function listProjects(filters, currentUser) { export async function listProjects(filters, currentUser) {
if (filters.trash && currentUser?.roleCode !== ROLES.SUPERADMIN) { if (filters.trash && currentUser?.roleCode !== ROLES.SUPERADMIN) {
throw new HttpError(403, 'Solo el superadministrador puede ver la papelera'); throw new HttpError(403, 'Solo el superadministrador puede ver la papelera');
} }
if (filters.trash || FULL_ACCESS_ROLES.includes(currentUser?.roleCode)) {
return repo.findAll(filters); 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); const project = await repo.findById(id);
if (!project) throw new HttpError(404, 'Proyecto no encontrado'); 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); const members = await repo.listMembers(id);
return { ...project, members }; return { ...project, members, isOwner: isProjectOwner(currentUser, project) };
} }
export async function createProject(payload, currentUser) { export async function createProject(payload, currentUser) {
@@ -48,10 +96,13 @@ export async function createProject(payload, currentUser) {
return project; 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) { export async function updateProject(id, payload, currentUser) {
const project = await repo.findById(id); const project = await repo.findById(id);
if (!project) throw new HttpError(404, 'Proyecto no encontrado'); 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) { if (!payload.name || !payload.priorityId || !payload.statusId) {
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios'); throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
} }
@@ -60,12 +111,12 @@ export async function updateProject(id, payload, currentUser) {
return updated; return updated;
} }
// Eliminacion logica: cualquiera que pueda editar el proyecto puede "eliminarlo" (deja de verse // Eliminacion logica: solo el dueño o un rol global de administracion. Solo el superadmin puede
// en el listado, pero sigue en la BD). Solo el superadmin puede restaurarlo o borrarlo definitivo. // restaurarlo o borrarlo definitivo (sin cambios).
export async function deleteProject(id, currentUser) { export async function deleteProject(id, currentUser) {
const project = await repo.findById(id); const project = await repo.findById(id);
if (!project) throw new HttpError(404, 'Proyecto no encontrado'); 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 repo.softDelete(id, currentUser.id);
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'project', entityId: 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 // 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 // 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. // 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) { export async function addMember(projectId, { userId, email }, roleInProject, currentUser) {
const project = await repo.findById(projectId); const project = await repo.findById(projectId);
if (!project) throw new HttpError(404, 'Proyecto no encontrado'); 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 targetUserId = userId;
let invited = false; let invited = false;
@@ -121,13 +179,14 @@ export async function addMember(projectId, { userId, email }, roleInProject, cur
return { members, invited }; 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); const project = await repo.findById(projectId);
if (!project) throw new HttpError(404, 'Proyecto no encontrado'); 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 cambiar permisos');
const members = await repo.updateMemberRole(projectId, userId, isAdmin ? 'admin' : null); roleInProject = normalizeRoleInProject(roleInProject);
const members = await repo.updateMemberRole(projectId, userId, roleInProject);
await logAudit({ 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 }, entityType: 'project', entityId: projectId, detail: { userId },
}); });
return members; return members;
@@ -136,7 +195,7 @@ export async function setMemberAdmin(projectId, userId, isAdmin, currentUser) {
export async function removeMember(projectId, userId, currentUser) { export async function removeMember(projectId, userId, currentUser) {
const project = await repo.findById(projectId); const project = await repo.findById(projectId);
if (!project) throw new HttpError(404, 'Proyecto no encontrado'); 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); const members = await repo.removeMember(projectId, userId);
await logAudit({ userId: currentUser.id, action: 'remove_member', entityType: 'project', entityId: projectId, detail: { userId } }); await logAudit({ userId: currentUser.id, action: 'remove_member', entityType: 'project', entityId: projectId, detail: { userId } });
return members; return members;
+10 -1
View File
@@ -2,7 +2,16 @@ import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js'; import * as service from './service.js';
export const getTasks = asyncHandler(async (req, res) => { 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) => { export const postTask = asyncHandler(async (req, res) => {
+25 -9
View File
@@ -1,11 +1,22 @@
import { pool } from '../../config/db.js'; 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) { export async function findByActivity(activityId) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT t.id, t.activity_id, t.name, t.responsible_user_id, u.name AS responsible_name, `${LIST_SELECT} WHERE t.activity_id = ? AND t.deleted_at IS NULL ORDER BY t.sort_order, t.created_at`,
t.is_done, t.due_date, t.sort_order, t.created_at [activityId]
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`, 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] [activityId]
); );
return rows; return rows;
@@ -19,8 +30,9 @@ export async function reorder(activityId, ids) {
} }
} }
export async function findById(id) { export async function findById(id, { includeDeleted = false } = {}) {
const [rows] = await pool.query('SELECT * FROM tasks WHERE id = ?', [id]); 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; return rows[0] || null;
} }
@@ -44,8 +56,12 @@ export async function setDone(id, isDone) {
return findById(id); return findById(id);
} }
export async function remove(id) { export async function softDelete(id, userId) {
await pool.query('DELETE FROM tasks WHERE id = ?', [id]); 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 // 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) { export async function countsByActivity(activityId) {
const [rows] = await pool.query( 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] [activityId]
); );
return { total: rows[0].total, done: Number(rows[0].done) || 0 }; return { total: rows[0].total, done: Number(rows[0].done) || 0 };
+3 -1
View File
@@ -1,15 +1,17 @@
import { Router } from 'express'; import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js'; 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(); const router = Router();
router.use(requireAuth); router.use(requireAuth);
router.get('/activities/:activityId/tasks', getTasks); router.get('/activities/:activityId/tasks', getTasks);
router.get('/activities/:activityId/tasks/trash', getTasksTrash);
router.post('/activities/:activityId/tasks', postTask); router.post('/activities/:activityId/tasks', postTask);
router.put('/activities/:activityId/tasks/reorder', putReorderTasks); router.put('/activities/:activityId/tasks/reorder', putReorderTasks);
router.put('/tasks/:id', putTask); router.put('/tasks/:id', putTask);
router.patch('/tasks/:id/done', patchTaskDone); router.patch('/tasks/:id/done', patchTaskDone);
router.delete('/tasks/:id', deleteTask); router.delete('/tasks/:id', deleteTask);
router.post('/tasks/:id/restore', postTaskRestore);
export default router; export default router;
+24 -2
View File
@@ -2,6 +2,7 @@ import * as repo from './repository.js';
import * as activitiesRepo from '../activities/repository.js'; import * as activitiesRepo from '../activities/repository.js';
import * as projectsRepo from '../projects/repository.js'; import * as projectsRepo from '../projects/repository.js';
import { canWriteActivity, recalcFromTasks } from '../activities/service.js'; import { canWriteActivity, recalcFromTasks } from '../activities/service.js';
import { canReadProject } from '../projects/service.js';
import { HttpError } from '../../utils/http.js'; import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js'; import { logAudit } from '../../utils/audit.js';
@@ -15,7 +16,18 @@ async function requireActivityAccess(activityId, currentUser) {
return activity; 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) { export async function createTask(activityId, payload, currentUser) {
await requireActivityAccess(activityId, currentUser); await requireActivityAccess(activityId, currentUser);
@@ -60,8 +72,18 @@ export async function removeTask(id, currentUser) {
const task = await repo.findById(id); const task = await repo.findById(id);
if (!task) throw new HttpError(404, 'Tarea no encontrada'); if (!task) throw new HttpError(404, 'Tarea no encontrada');
await requireActivityAccess(task.activity_id, currentUser); 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 }); await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'task', entityId: id });
const counts = await repo.countsByActivity(task.activity_id); const counts = await repo.countsByActivity(task.activity_id);
await recalcFromTasks(task.activity_id, counts.done, counts.total); 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);
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "2.4.0", "version": "2.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+20 -10
View File
@@ -168,12 +168,12 @@ export const messages = {
project_risk: 'Riesgo', project_risk: 'Riesgo',
project_notes: 'Observaciones', project_notes: 'Observaciones',
project_members: 'Miembros', project_members: 'Miembros',
project_add_member: 'Agregar miembro', project_members_owner_only_hint: 'Solo el dueño del proyecto puede compartirlo y cambiar permisos.',
project_add_member_as_admin: 'Agregar como administrador del proyecto', project_add_member: 'Compartir proyecto',
project_add_member_role: 'Permiso',
project_role_owner: 'Dueño', project_role_owner: 'Dueño',
project_role_admin: 'Administrador', project_role_read: 'Lectura',
project_make_admin: 'Hacer administrador', project_role_write: 'Escritura',
project_remove_admin: 'Quitar administrador',
project_invite_email: 'Invitar por correo', 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_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', project_invite_sent: 'Invitacion enviada por correo',
@@ -215,6 +215,11 @@ export const messages = {
project_trash_title: 'Papelera de proyectos', project_trash_title: 'Papelera de proyectos',
project_trash_back: 'Volver a proyectos', project_trash_back: 'Volver a proyectos',
project_trash_empty_hint: 'Los proyectos eliminados aparecen aqui hasta que se restauran o se borran definitivamente.', 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_restore: 'Restaurar',
project_delete_permanent: 'Eliminar definitivamente', 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.', 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_risk: 'Risk',
project_notes: 'Notes', project_notes: 'Notes',
project_members: 'Members', project_members: 'Members',
project_add_member: 'Add member', project_members_owner_only_hint: 'Only the project owner can share it and change permissions.',
project_add_member_as_admin: 'Add as project administrator', project_add_member: 'Share project',
project_add_member_role: 'Permission',
project_role_owner: 'Owner', project_role_owner: 'Owner',
project_role_admin: 'Administrator', project_role_read: 'Read',
project_make_admin: 'Make administrator', project_role_write: 'Write',
project_remove_admin: 'Remove administrator',
project_invite_email: 'Invite by email', 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_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', project_invite_sent: 'Invite sent by email',
@@ -508,6 +513,11 @@ export const messages = {
project_trash_title: 'Project trash', project_trash_title: 'Project trash',
project_trash_back: 'Back to projects', project_trash_back: 'Back to projects',
project_trash_empty_hint: 'Deleted projects show up here until they are restored or permanently deleted.', 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_restore: 'Restore',
project_delete_permanent: 'Delete permanently', 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.', project_delete_permanent_confirm_msg: 'This CANNOT be undone: the project and all its activities, comments and attachments will be permanently deleted.',
@@ -1,11 +1,11 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; 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 { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx'; import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js'; import { useAsync, useAction } from '../../hooks/useAsync.js';
import { useDebouncedValue } from '../../hooks/useDebouncedValue.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 { listProjects } from '../../services/projects.js';
import { search as searchApi } from '../../services/search.js'; import { search as searchApi } from '../../services/search.js';
import { listCatalog } from '../../services/catalogs.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 }; 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 { t, pushToast, openDialog } = useUi();
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const runAction = useAction(); const runAction = useAction();
const { data: activities, reload } = useAsync(() => listActivitiesByProject(projectId), [projectId]); 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: priorities } = useAsync(() => listCatalog('priorities'), []);
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []); const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
const { data: users } = useAsync(listUsers, []); const { data: users } = useAsync(listUsers, []);
@@ -213,9 +226,22 @@ export default function ProjectActivitiesTab({ projectId, openActivityId, openAc
<SearchResultsDropdown items={searchItems} emptyLabel={t('common_search_no_results')} /> <SearchResultsDropdown items={searchItems} emptyLabel={t('common_search_no_results')} />
)} )}
</div> </div>
{project?.isOwner && (
<Button variant="standard" icon={Archive} onClick={() => setTrashOpen(true)}>{t('activities_trash')}</Button>
)}
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}</Button> <Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}</Button>
</div> </div>
<DetailPopup open={trashOpen} title={t('activities_trash')} onClose={() => setTrashOpen(false)} width={480} height={420}>
{(trashed || []).length === 0 && <div className="text-muted" style={{ fontSize: '.82rem' }}>{t('activities_trash_empty')}</div>}
{(trashed || []).map((a) => (
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0', borderBottom: '1px solid rgb(var(--line))' }}>
<span style={{ flex: 1, fontSize: '.82rem' }}>{a.code} — {a.name}</span>
<Button variant="standard" icon={RotateCcw} onClick={() => restoreFromTrash(a)}>{t('common_restore')}</Button>
</div>
))}
</DetailPopup>
<ListView <ListView
columns={columns} columns={columns}
rows={activities} rows={activities}
@@ -1,8 +1,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { UserPlus, Trash2, ShieldCheck, ShieldOff } from 'lucide-react'; import { UserPlus, Trash2 } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx'; import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js'; import { useAsync, useAction } from '../../hooks/useAsync.js';
import { addProjectMember, setProjectMemberAdmin, removeProjectMember } from '../../services/projects.js'; import { addProjectMember, setProjectMemberRole, removeProjectMember } from '../../services/projects.js';
import { listUsers } from '../../services/users.js'; import { listUsers } from '../../services/users.js';
import { Button } from '../../components/Button.jsx'; import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx'; import { Field } from '../../components/Field.jsx';
@@ -10,20 +11,29 @@ import { ComboBox } from '../../components/ComboBox.jsx';
import { Badge } from '../../components/Badge.jsx'; import { Badge } from '../../components/Badge.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx'; import { DetailPopup } from '../../components/DetailPopup.jsx';
const ROLE_BADGE = { owner: { color: 'accent', key: 'project_role_owner' }, admin: { color: 'success', key: 'project_role_admin' } }; const ROLE_BADGE = {
owner: { color: 'accent', key: 'project_role_owner' },
write: { color: 'success', key: 'project_role_write' },
};
export default function ProjectMembersTab({ project, onChanged }) { export default function ProjectMembersTab({ project, onChanged }) {
const { t, openDialog, pushToast } = useUi(); const { t, openDialog, pushToast } = useUi();
const { user: currentUser } = useAuth();
const runAction = useAction(); const runAction = useAction();
const { data: users } = useAsync(listUsers, []); const { data: users } = useAsync(listUsers, []);
const [addingMember, setAddingMember] = useState(''); const [addingMember, setAddingMember] = useState('');
const [inviteEmail, setInviteEmail] = useState(''); const [inviteEmail, setInviteEmail] = useState('');
const [addAsAdmin, setAddAsAdmin] = useState(false); const [newMemberRole, setNewMemberRole] = useState('read');
const [addMemberOpen, setAddMemberOpen] = useState(false); const [addMemberOpen, setAddMemberOpen] = useState(false);
// Compartir (agregar/quitar/cambiar permiso) es exclusivo del dueño del proyecto -- el resto
// solo ve la lista de con quien esta compartido.
const canManage = !!project.isOwner || currentUser?.id === project.owner_user_id;
const userOptions = (users || []).filter((u) => !project.members.some((m) => m.user_id === u.id)).map((u) => ({ value: u.id, label: u.name })); const userOptions = (users || []).filter((u) => !project.members.some((m) => m.user_id === u.id)).map((u) => ({ value: u.id, label: u.name }));
const canAdd = !!addingMember || !!inviteEmail.trim(); const canAdd = !!addingMember || !!inviteEmail.trim();
const roleOptions = [{ value: 'read', label: t('project_role_read') }, { value: 'write', label: t('project_role_write') }];
async function addMember() { async function addMember() {
if (!canAdd) return; if (!canAdd) return;
@@ -31,19 +41,19 @@ export default function ProjectMembersTab({ project, onChanged }) {
const target = inviteEmail.trim() const target = inviteEmail.trim()
? { email: inviteEmail.trim() } ? { email: inviteEmail.trim() }
: { userId: Number(addingMember) }; : { 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(''); setAddingMember('');
setInviteEmail(''); setInviteEmail('');
setAddAsAdmin(false); setNewMemberRole('read');
setAddMemberOpen(false); setAddMemberOpen(false);
onChanged(); onChanged();
pushToast({ tone: 'success', message: result.invited ? t('project_invite_sent') : t('common_saved') }); pushToast({ tone: 'success', message: result.invited ? t('project_invite_sent') : t('common_saved') });
}); });
} }
function toggleAdmin(member) { function changeRole(member, role) {
runAction(async () => { 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(); onChanged();
}); });
} }
@@ -65,31 +75,35 @@ export default function ProjectMembersTab({ project, onChanged }) {
<div className="panel-card"> <div className="panel-card">
<div className="toolbar"> <div className="toolbar">
<h3 style={{ margin: 0 }}>{t('project_members')}</h3> <h3 style={{ margin: 0 }}>{t('project_members')}</h3>
<Button variant="accent" icon={UserPlus} onClick={() => setAddMemberOpen(true)}>{t('project_add_member')}</Button> {canManage && <Button variant="accent" icon={UserPlus} onClick={() => setAddMemberOpen(true)}>{t('project_add_member')}</Button>}
</div> </div>
{!canManage && <div className="text-muted" style={{ fontSize: '.76rem', marginBottom: 10 }}>{t('project_members_owner_only_hint')}</div>}
{project.members.map((m) => { {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 ( return (
<div key={m.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}> <div key={m.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
<span style={{ flex: 1, fontSize: '.82rem' }}>{m.name} <span className="text-muted">· {m.email}</span></span> <span style={{ flex: 1, fontSize: '.82rem' }}>{m.name} <span className="text-muted">· {m.email}</span></span>
{badge && <Badge color={badge.color}>{t(badge.key)}</Badge>} {canManage && m.role_in_project !== 'owner' ? (
{m.role_in_project !== 'owner' && ( <div style={{ width: 130 }}>
<button <ComboBox
className="icon-btn" options={roleOptions}
title={m.role_in_project === 'admin' ? t('project_remove_admin') : t('project_make_admin')} value={m.role_in_project === 'write' ? 'write' : 'read'}
onClick={() => toggleAdmin(m)} onChange={(v) => changeRole(m, v)}
> />
{m.role_in_project === 'admin' ? <ShieldOff size={16} /> : <ShieldCheck size={16} />} </div>
</button> ) : (
<Badge color={badge.color}>{t(badge.key)}</Badge>
)} )}
{canManage && m.role_in_project !== 'owner' && (
<button className="icon-btn" onClick={() => confirmRemoveMember(m)}><Trash2 size={16} /></button> <button className="icon-btn" onClick={() => confirmRemoveMember(m)}><Trash2 size={16} /></button>
)}
</div> </div>
); );
})} })}
{project.members.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>} {project.members.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
</div> </div>
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => setAddMemberOpen(false)} width={420} height={380}> <DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => setAddMemberOpen(false)} width={420} height={420}>
<Field label={t('project_add_member')}> <Field label={t('project_add_member')}>
<ComboBox <ComboBox
options={userOptions} options={userOptions}
@@ -106,10 +120,9 @@ export default function ProjectMembersTab({ project, onChanged }) {
placeholder="correo@dominio.com" placeholder="correo@dominio.com"
/> />
</Field> </Field>
<label className="checkbox-row"> <Field label={t('project_add_member_role')}>
<input type="checkbox" checked={addAsAdmin} onChange={(e) => setAddAsAdmin(e.target.checked)} /> <ComboBox options={roleOptions} value={newMemberRole} onChange={setNewMemberRole} />
{t('project_add_member_as_admin')} </Field>
</label>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}> <div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="accent" icon={UserPlus} onClick={addMember} disabled={!canAdd}>{t('common_save')}</Button> <Button variant="accent" icon={UserPlus} onClick={addMember} disabled={!canAdd}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setAddMemberOpen(false)}>{t('common_cancel')}</Button> <Button variant="standard" onClick={() => setAddMemberOpen(false)}>{t('common_cancel')}</Button>
@@ -58,7 +58,7 @@ export default function ProyectoDetallePage() {
{tab === 'info' && <ProjectInfoTab project={project} onChanged={reload} />} {tab === 'info' && <ProjectInfoTab project={project} onChanged={reload} />}
{tab === 'members' && <ProjectMembersTab project={project} onChanged={reload} />} {tab === 'members' && <ProjectMembersTab project={project} onChanged={reload} />}
{tab === 'activities' && ( {tab === 'activities' && (
<ProjectActivitiesTab projectId={projectId} openActivityId={deepLinkActivityId} openActivityTab={deepLinkActivityTab} /> <ProjectActivitiesTab project={project} projectId={projectId} openActivityId={deepLinkActivityId} openActivityTab={deepLinkActivityTab} />
)} )}
{tab === 'gantt' && <ProjectGanttTab projectId={projectId} />} {tab === 'gantt' && <ProjectGanttTab projectId={projectId} />}
{tab === 'calendar' && <ProjectCalendarTab projectId={projectId} />} {tab === 'calendar' && <ProjectCalendarTab projectId={projectId} />}
+27 -2
View File
@@ -6,6 +6,7 @@ import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js'; import { useAsync, useAction } from '../../hooks/useAsync.js';
import { useDebouncedValue } from '../../hooks/useDebouncedValue.js'; import { useDebouncedValue } from '../../hooks/useDebouncedValue.js';
import { listProjects, createProject, deleteProject, restoreProject, permanentlyDeleteProject } from '../../services/projects.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 { search as searchApi } from '../../services/search.js';
import { listCatalog } from '../../services/catalogs.js'; import { listCatalog } from '../../services/catalogs.js';
import { listDepartments } from '../../services/departments.js'; import { listDepartments } from '../../services/departments.js';
@@ -46,11 +47,16 @@ export default function ProyectosPage() {
const [filterStatusId, setFilterStatusId] = useState(''); const [filterStatusId, setFilterStatusId] = useState('');
const [filterPriorityId, setFilterPriorityId] = useState(''); const [filterPriorityId, setFilterPriorityId] = useState('');
const [filterDueSoon, setFilterDueSoon] = useState(false); 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( const { data: projects, reload } = useAsync(
() => listProjects(showTrash ? { trash: true } : { statusId: filterStatusId, priorityId: filterPriorityId }), () => listProjects(showTrash ? { trash: true } : { statusId: filterStatusId, priorityId: filterPriorityId, ownerUserId: filterOwnerUserId || undefined }),
[showTrash, filterStatusId, filterPriorityId] [showTrash, filterStatusId, filterPriorityId, filterOwnerUserId]
); );
const { data: allUsers } = useAsync(listUsers, []);
const { data: priorities } = useAsync(() => listCatalog('priorities'), []); const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []); const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []);
const { data: types } = useAsync(() => listCatalog('project-types'), []); const { data: types } = useAsync(() => listCatalog('project-types'), []);
@@ -207,12 +213,31 @@ export default function ProyectosPage() {
</div> </div>
{!showTrash && ( {!showTrash && (
<> <>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="text-muted" style={{ fontSize: '.72rem', whiteSpace: 'nowrap' }}>{t('common_status')}</span>
<div style={{ width: 160 }}> <div style={{ width: 160 }}>
<ComboBox options={[{ value: '', label: t('common_all') }, ...statusOptions]} value={filterStatusId} onChange={setFilterStatusId} placeholder={t('common_status')} /> <ComboBox options={[{ value: '', label: t('common_all') }, ...statusOptions]} value={filterStatusId} onChange={setFilterStatusId} placeholder={t('common_status')} />
</div> </div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="text-muted" style={{ fontSize: '.72rem', whiteSpace: 'nowrap' }}>{t('common_priority')}</span>
<div style={{ width: 160 }}> <div style={{ width: 160 }}>
<ComboBox options={[{ value: '', label: t('common_all') }, ...priorityOptions]} value={filterPriorityId} onChange={setFilterPriorityId} placeholder={t('common_priority')} /> <ComboBox options={[{ value: '', label: t('common_all') }, ...priorityOptions]} value={filterPriorityId} onChange={setFilterPriorityId} placeholder={t('common_priority')} />
</div> </div>
</div>
{canFilterByUser && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="text-muted" style={{ fontSize: '.72rem', whiteSpace: 'nowrap' }}>Usuario</span>
<div style={{ width: 200 }}>
<ComboBox
options={[{ value: '', label: t('common_all') }, ...(allUsers || []).map((u) => ({ value: u.id, label: u.name }))]}
value={filterOwnerUserId}
onChange={setFilterOwnerUserId}
placeholder="Usuario"
/>
</div>
</div>
)}
<label className="checkbox-row"> <label className="checkbox-row">
<input type="checkbox" checked={filterDueSoon} onChange={(e) => setFilterDueSoon(e.target.checked)} /> <input type="checkbox" checked={filterDueSoon} onChange={(e) => setFilterDueSoon(e.target.checked)} />
{t('project_filter_due_soon')} {t('project_filter_due_soon')}
+2
View File
@@ -5,6 +5,8 @@ export const createActivity = (projectId, data) => api.post(`/projects/${project
export const getActivity = (id) => api.get(`/activities/${id}`); export const getActivity = (id) => api.get(`/activities/${id}`);
export const updateActivity = (id, data) => api.put(`/activities/${id}`, data); export const updateActivity = (id, data) => api.put(`/activities/${id}`, data);
export const deleteActivity = (id) => api.delete(`/activities/${id}`); export const deleteActivity = (id) => api.delete(`/activities/${id}`);
export const restoreActivity = (id) => api.post(`/activities/${id}/restore`);
export const listActivitiesTrash = (projectId) => api.get(`/projects/${projectId}/activities/trash`);
export const moveActivity = (id, projectId) => api.put(`/activities/${id}/move`, { projectId }); export const moveActivity = (id, projectId) => api.put(`/activities/${id}/move`, { projectId });
export const convertActivityToProject = (id, projectName, taskActivityName) => export const convertActivityToProject = (id, projectName, taskActivityName) =>
api.post(`/activities/${id}/convert-to-project`, { projectName, taskActivityName }); api.post(`/activities/${id}/convert-to-project`, { projectName, taskActivityName });
+1 -1
View File
@@ -11,6 +11,6 @@ export const deleteProject = (id) => api.delete(`/projects/${id}`);
export const restoreProject = (id) => api.post(`/projects/${id}/restore`); export const restoreProject = (id) => api.post(`/projects/${id}/restore`);
export const permanentlyDeleteProject = (id) => api.delete(`/projects/${id}/permanent`); export const permanentlyDeleteProject = (id) => api.delete(`/projects/${id}/permanent`);
export const addProjectMember = (id, { userId, email }, roleInProject) => api.post(`/projects/${id}/members`, { userId, email, roleInProject }); export const addProjectMember = (id, { userId, email }, roleInProject) => api.post(`/projects/${id}/members`, { userId, email, roleInProject });
export const setProjectMemberAdmin = (id, userId, admin) => api.put(`/projects/${id}/members/${userId}/role`, { admin }); export const setProjectMemberRole = (id, userId, role) => api.put(`/projects/${id}/members/${userId}/role`, { role });
export const removeProjectMember = (id, userId) => api.delete(`/projects/${id}/members/${userId}`); export const removeProjectMember = (id, userId) => api.delete(`/projects/${id}/members/${userId}`);
export const getGantt = (id) => api.get(`/projects/${id}/gantt`); export const getGantt = (id) => api.get(`/projects/${id}/gantt`);
+2
View File
@@ -5,4 +5,6 @@ export const createTask = (activityId, data) => api.post(`/activities/${activity
export const updateTask = (id, data) => api.put(`/tasks/${id}`, data); export const updateTask = (id, data) => api.put(`/tasks/${id}`, data);
export const setTaskDone = (id, isDone) => api.patch(`/tasks/${id}/done`, { isDone }); export const setTaskDone = (id, isDone) => api.patch(`/tasks/${id}/done`, { isDone });
export const deleteTask = (id) => api.delete(`/tasks/${id}`); export const deleteTask = (id) => api.delete(`/tasks/${id}`);
export const restoreTask = (id) => api.post(`/tasks/${id}/restore`);
export const listTasksTrash = (activityId) => api.get(`/activities/${activityId}/tasks/trash`);
export const reorderTasks = (activityId, ids) => api.put(`/activities/${activityId}/tasks/reorder`, { ids }); export const reorderTasks = (activityId, ids) => api.put(`/activities/${activityId}/tasks/reorder`, { ids });