feat: eliminar proyecto (logico), papelera para superadmin y orden por creacion

- DELETE /api/projects/🆔 eliminacion logica (deleted_at/deleted_by), mismo
  permiso que editar el proyecto. Popup de confirmacion antes de aplicarla.
- Papelera (GET /api/projects?trash=true, POST /:id/restore, DELETE
  /:id/permanent) restringida al superadmin; el borrado fisico exige que el
  proyecto ya este en la papelera y arrastra actividades/comentarios/adjuntos
  por ON DELETE CASCADE.
- Dashboard (KPIs, actividades pendientes/calendario, proyectos por estado)
  excluye proyectos eliminados logicamente.
- Columna "Creado" en el listado de proyectos, ordenado por defecto de mas
  nuevo a mas antiguo (ListView admite initialSort ademas del orden por clic
  en cabecera que ya tenia).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-09 09:26:35 -05:00
parent dd7caf51c7
commit 5a25ed066d
16 changed files with 247 additions and 33 deletions
@@ -0,0 +1,8 @@
-- Eliminacion logica de proyectos: deleted_at/deleted_by. Idempotente.
SET NAMES utf8mb4;
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL,
ADD COLUMN IF NOT EXISTS deleted_by INT NULL;
CREATE INDEX IF NOT EXISTS idx_projects_deleted_at ON projects(deleted_at);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"type": "module",
"main": "src/server.js",
+17 -10
View File
@@ -2,30 +2,37 @@ import { pool } from '../../config/db.js';
export async function summary() {
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`
`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`
);
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.estimated_end_date IS NOT NULL AND p.estimated_end_date < CURDATE()`
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()`
);
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.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)`
);
const [[pendingActivities]] = await pool.query(
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id WHERE st.is_closed = 0`
`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`
);
const [[overdueActivities]] = await pool.query(
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id
WHERE st.is_closed = 0 AND a.due_date IS NOT NULL AND a.due_date < CURDATE()`
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()`
);
const [[todayActivities]] = await pool.query(
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id
WHERE st.is_closed = 0 AND a.due_date = CURDATE()`
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()`
);
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`
`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`
);
return {
@@ -43,7 +50,7 @@ export async function myPendingActivities(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 a.responsible_user_id = ?
WHERE st.is_closed = 0 AND p.deleted_at IS NULL AND a.responsible_user_id = ?
ORDER BY (a.due_date IS NULL), a.due_date LIMIT 20`,
[userId]
);
@@ -55,7 +62,7 @@ export async function calendarActivities(monthStart, monthEnd) {
`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 ?
WHERE a.due_date BETWEEN ? AND ? AND p.deleted_at IS NULL
ORDER BY a.due_date`,
[monthStart, monthEnd]
);
@@ -65,7 +72,7 @@ export async function calendarActivities(monthStart, monthEnd) {
export async function projectsByStatus() {
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
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`
);
return rows;
+17 -2
View File
@@ -2,8 +2,8 @@ import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
export const getProjects = asyncHandler(async (req, res) => {
const { statusId, priorityId, ownerUserId } = req.query;
ok(res, await service.listProjects({ statusId, priorityId, ownerUserId }));
const { statusId, priorityId, ownerUserId, trash } = req.query;
ok(res, await service.listProjects({ statusId, priorityId, ownerUserId, trash: trash === 'true' }, req.currentUser));
});
export const getProject = asyncHandler(async (req, res) => {
@@ -18,6 +18,21 @@ export const putProject = asyncHandler(async (req, res) => {
ok(res, await service.updateProject(Number(req.params.id), req.body, req.currentUser));
});
export const deleteProject = asyncHandler(async (req, res) => {
await service.deleteProject(Number(req.params.id), req.currentUser);
ok(res, { deleted: true });
});
export const postProjectRestore = asyncHandler(async (req, res) => {
await service.restoreProject(Number(req.params.id), req.currentUser);
ok(res, { restored: true });
});
export const deleteProjectPermanent = asyncHandler(async (req, res) => {
await service.permanentlyDeleteProject(Number(req.params.id), req.currentUser);
ok(res, { deleted: true });
});
export const postMember = asyncHandler(async (req, res) => {
const members = await service.addMember(Number(req.params.id), Number(req.body.userId), req.body.roleInProject, req.currentUser);
ok(res, members, 201);
+19 -6
View File
@@ -8,7 +8,7 @@ const LIST_SELECT = `
p.requesting_department_id, rd.name AS requesting_department_name,
p.responsible_department_id, sd.name AS responsible_department_name,
p.start_date, p.estimated_end_date, p.actual_end_date, p.progress_pct,
p.risk_level, p.notes, p.created_by, p.created_at, p.updated_at
p.risk_level, p.notes, p.created_by, p.created_at, p.updated_at, p.deleted_at, p.deleted_by
FROM projects p
JOIN priorities pr ON pr.id = p.priority_id
JOIN project_statuses ps ON ps.id = p.status_id
@@ -18,22 +18,35 @@ const LIST_SELECT = `
LEFT JOIN departments sd ON sd.id = p.responsible_department_id
`;
export async function findAll({ statusId, priorityId, ownerUserId } = {}) {
const clauses = [];
export async function findAll({ statusId, priorityId, ownerUserId, 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); }
const where = clauses.length ? `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);
return rows;
}
export async function findById(id) {
const [rows] = await pool.query(`${LIST_SELECT} WHERE p.id = ?`, [id]);
export async function findById(id, { includeDeleted = false } = {}) {
const where = includeDeleted ? 'p.id = ?' : 'p.id = ? AND p.deleted_at IS NULL';
const [rows] = await pool.query(`${LIST_SELECT} WHERE ${where}`, [id]);
return rows[0] || null;
}
export async function softDelete(id, userId) {
await pool.query('UPDATE projects SET deleted_at = NOW(), deleted_by = ? WHERE id = ? AND deleted_at IS NULL', [userId, id]);
}
export async function restore(id) {
await pool.query('UPDATE projects SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', [id]);
}
export async function hardDelete(id) {
await pool.query('DELETE FROM projects WHERE id = ?', [id]);
}
export async function create(data) {
const [result] = await pool.query(
`INSERT INTO projects
+7 -1
View File
@@ -1,6 +1,9 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { getProjects, getProject, postProject, putProject, postMember, deleteMember } from './controller.js';
import {
getProjects, getProject, postProject, putProject, deleteProject, postProjectRestore,
deleteProjectPermanent, postMember, deleteMember,
} from './controller.js';
import { getGantt } from '../dashboard/controller.js';
import { getActivitiesByProject, postActivity } from '../activities/controller.js';
@@ -11,6 +14,9 @@ router.get('/', getProjects);
router.post('/', postProject);
router.get('/:id', getProject);
router.put('/:id', putProject);
router.delete('/:id', deleteProject);
router.post('/:id/restore', postProjectRestore);
router.delete('/:id/permanent', deleteProjectPermanent);
router.get('/:id/gantt', getGantt);
router.post('/:id/members', postMember);
router.delete('/:id/members/:userId', deleteMember);
+36 -1
View File
@@ -11,7 +11,13 @@ export async function canWriteProject(user, project) {
return repo.isMember(project.id, user.id);
}
export const listProjects = (filters) => repo.findAll(filters);
// 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);
}
export async function getProject(id) {
const project = await repo.findById(id);
@@ -48,6 +54,35 @@ 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.
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');
await repo.softDelete(id, currentUser.id);
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'project', entityId: id });
}
export async function restoreProject(id, currentUser) {
if (currentUser.roleCode !== ROLES.SUPERADMIN) throw new HttpError(403, 'Solo el superadministrador puede restaurar proyectos');
const project = await repo.findById(id, { includeDeleted: true });
if (!project || !project.deleted_at) throw new HttpError(404, 'Proyecto no encontrado en la papelera');
await repo.restore(id);
await logAudit({ userId: currentUser.id, action: 'restore', entityType: 'project', entityId: id });
}
// Borrado fisico e irreversible (arrastra actividades/comentarios/adjuntos por ON DELETE CASCADE).
// Solo el superadmin, y solo sobre un proyecto que ya paso por eliminacion logica primero.
export async function permanentlyDeleteProject(id, currentUser) {
if (currentUser.roleCode !== ROLES.SUPERADMIN) throw new HttpError(403, 'Solo el superadministrador puede eliminar proyectos definitivamente');
const project = await repo.findById(id, { includeDeleted: true });
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!project.deleted_at) throw new HttpError(400, 'El proyecto debe eliminarse primero antes de poder borrarlo definitivamente');
await repo.hardDelete(id);
await logAudit({ userId: currentUser.id, action: 'permanent_delete', entityType: 'project', entityId: id });
}
// Recalcula el avance del proyecto como promedio del avance de sus actividades.
export async function recalcProjectProgress(projectId) {
const avg = await repo.averageActivityProgress(projectId);