feat: eliminar actividad, bloqueado si tiene tareas pendientes

No existia forma de borrar una actividad. Agregado DELETE
/api/activities/:id con boton "Eliminar" en el listado (popup de
confirmacion). Si tiene tareas, se bloquea con 400 y mensaje claro en
vez de arrastrar todo en cascada sin avisar.

De paso: la logica de borrar adjunto (fila + archivo en disco) se
extrajo a un attachments/service.js reutilizable, usado tanto por el
borrado individual como por el borrado en cascada de una actividad
(evita archivos huerfanos). Comentarios de la actividad tambien se
limpian (sin FK fisica cruzada, entity_type/entity_id es polimorfico).

Probado en dev con curl (bloquea con tarea, permite sin tareas, 404
tras borrar) y Playwright (mensaje de advertencia visible).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-15 12:30:53 -05:00
parent 5d48cc6d32
commit 5e0bc0809e
14 changed files with 110 additions and 10 deletions
+3
View File
@@ -71,6 +71,9 @@
### Actividades (`/api/activities`) ### Actividades (`/api/activities`)
- `GET /api/projects/:projectId/activities` · `POST /api/projects/:projectId/activities` - `GET /api/projects/:projectId/activities` · `POST /api/projects/:projectId/activities`
- `GET /api/activities/:id` · `PUT /api/activities/:id` - `GET /api/activities/:id` · `PUT /api/activities/:id`
- `DELETE /api/activities/:id` — bloqueado con `400` si la actividad tiene tareas (hay que
eliminarlas primero); limpia comentarios/adjuntos de la actividad de paso (no hay FK física
cruzada). Arrastra `activity_dependencies` por `ON DELETE CASCADE`.
- `POST /api/activities/:id/dependencies` · `DELETE /api/activities/:id/dependencies/:dependsOnId` - `POST /api/activities/:id/dependencies` · `DELETE /api/activities/:id/dependencies/:dependsOnId`
### Tareas (`/api/activities/:activityId/tasks`) ### Tareas (`/api/activities/:activityId/tasks`)
+11
View File
@@ -1,5 +1,16 @@
# Changelog - project.linkedcorp.net # Changelog - project.linkedcorp.net
## [1.13.0] - 2026-09-15
### Added
- Eliminar actividad: `DELETE /api/activities/:id` (no existía) + botón "Eliminar" en el listado
de actividades, con popup de confirmación. Si la actividad tiene tareas, se bloquea con un
mensaje claro ("Esta actividad tiene N tarea(s). Elimina primero las tareas antes de borrar la
actividad.") en vez de borrar en cascada silenciosamente.
### Changed
- Adjuntos: la lógica de borrar archivo + fila (antes solo inline en el controller) se movió a un
nuevo `attachments/service.js` reutilizable, usado tanto por el borrado individual como por el
borrado en cascada al eliminar una actividad (evita archivos huérfanos en disco).
## [1.12.5] - 2026-09-14 ## [1.12.5] - 2026-09-14
### Changed ### Changed
- Listado de Proyectos: columna "Creado" reemplazada por "Actualizado" (fecha y hora), y es ahora - Listado de Proyectos: columna "Creado" reemplazada por "Actualizado" (fecha y hora), y es ahora
+1 -1
View File
@@ -1 +1 @@
1.12.5 1.13.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "1.12.5", "version": "1.13.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
@@ -17,6 +17,11 @@ export const putActivity = asyncHandler(async (req, res) => {
ok(res, await service.updateActivity(Number(req.params.id), req.body, req.currentUser)); ok(res, await service.updateActivity(Number(req.params.id), req.body, req.currentUser));
}); });
export const deleteActivity = asyncHandler(async (req, res) => {
await service.removeActivity(Number(req.params.id), req.currentUser);
ok(res, { deleted: true });
});
export const postDependency = asyncHandler(async (req, res) => { export const postDependency = asyncHandler(async (req, res) => {
const deps = await service.addDependency(Number(req.params.id), Number(req.body.dependsOnActivityId), req.currentUser); const deps = await service.addDependency(Number(req.params.id), Number(req.body.dependsOnActivityId), req.currentUser);
ok(res, deps, 201); ok(res, deps, 201);
@@ -59,6 +59,11 @@ 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 remove(id) {
await pool.query('DELETE FROM activities WHERE id = ?', [id]);
}
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 = ?', [id]);
return rows[0].c; return rows[0].c;
+2 -1
View File
@@ -1,12 +1,13 @@
import { Router } from 'express'; import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js'; import { requireAuth } from '../../middleware/auth.js';
import { getActivity, putActivity, postDependency, deleteDependency } from './controller.js'; import { getActivity, putActivity, deleteActivity, postDependency, deleteDependency } from './controller.js';
const router = Router(); const router = Router();
router.use(requireAuth); router.use(requireAuth);
router.get('/:id', getActivity); router.get('/:id', getActivity);
router.put('/:id', putActivity); router.put('/:id', putActivity);
router.delete('/:id', deleteActivity);
router.post('/:id/dependencies', postDependency); router.post('/:id/dependencies', postDependency);
router.delete('/:id/dependencies/:dependsOnId', deleteDependency); router.delete('/:id/dependencies/:dependsOnId', deleteDependency);
+23
View File
@@ -1,5 +1,7 @@
import * as repo from './repository.js'; 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 attachmentsService from '../attachments/service.js';
import { recalcProjectProgress, canWriteProject } from '../projects/service.js'; import { recalcProjectProgress, 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';
@@ -95,6 +97,27 @@ export async function updateActivity(id, payload, currentUser) {
return repo.findById(id); return repo.findById(id);
} }
// Bloquea el borrado si hay tareas pendientes (a proposito: evita perder de vista trabajo real
// hecho/por hacer solo porque alguien borro la actividad de un clic). El usuario debe vaciar el
// checklist primero. Comentarios/adjuntos si se limpian solos, no llevan informacion de progreso.
export async function removeActivity(id, currentUser) {
const activity = await repo.findById(id);
if (!activity) throw new HttpError(404, 'Actividad no encontrada');
const project = await getProjectOr404(activity.project_id);
if (!(await canWriteActivity(currentUser, project, activity))) {
throw new HttpError(403, 'No tienes permisos sobre esta actividad');
}
const taskCount = await repo.countTasks(id);
if (taskCount > 0) {
throw new HttpError(400, `Esta actividad tiene ${taskCount} tarea(s). Elimina primero las tareas antes de borrar la actividad.`);
}
await commentsRepo.removeByEntity('activity', id);
await attachmentsService.removeAllForEntity('activity', id);
await repo.remove(id);
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'activity', entityId: id });
await recalcProjectProgress(activity.project_id);
}
export async function addDependency(activityId, dependsOnActivityId, currentUser) { export async function addDependency(activityId, dependsOnActivityId, currentUser) {
if (activityId === dependsOnActivityId) throw new HttpError(400, 'Una actividad no puede depender de si misma'); if (activityId === dependsOnActivityId) throw new HttpError(400, 'Una actividad no puede depender de si misma');
const activity = await repo.findById(activityId); const activity = await repo.findById(activityId);
@@ -5,6 +5,7 @@ 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 * as repo from './repository.js'; import * as repo from './repository.js';
import { removeAttachment } from './service.js';
const VALID_ENTITY_TYPES = new Set(['project', 'activity']); const VALID_ENTITY_TYPES = new Set(['project', 'activity']);
@@ -43,9 +44,7 @@ export const deleteAttachment = asyncHandler(async (req, res) => {
const canDelete = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(req.currentUser.roleCode) || attachment.uploaded_by === req.currentUser.id; const canDelete = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(req.currentUser.roleCode) || 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 repo.remove(attachment.id); await removeAttachment(attachment);
const filePath = path.join(uploadsDir, attachment.filename);
fs.promises.unlink(filePath).catch(() => {});
await logAudit({ userId: req.currentUser.id, action: 'delete', entityType: attachment.entity_type, entityId: attachment.entity_id, detail: { attachmentId: attachment.id } }); await logAudit({ userId: req.currentUser.id, action: 'delete', entityType: attachment.entity_type, entityId: attachment.entity_id, detail: { attachmentId: attachment.id } });
ok(res, { deleted: true }); ok(res, { deleted: true });
}); });
@@ -0,0 +1,22 @@
import path from 'node:path';
import fs from 'node:fs';
import { uploadsDir } from '../../middleware/upload.js';
import * as repo from './repository.js';
async function unlinkQuiet(filename) {
await fs.promises.unlink(path.join(uploadsDir, filename)).catch(() => {});
}
// Borra la fila y el archivo en disco. Unico lugar que hace esto — reutilizado tanto por el borrado
// individual (controller) como por el borrado en cascada al eliminar la entidad duena.
export async function removeAttachment(attachment) {
await repo.remove(attachment.id);
await unlinkQuiet(attachment.filename);
}
// Usado al borrar la entidad duena (actividad, proyecto): limpia sus adjuntos (fila + archivo) de
// paso, ya que no hay FK fisica cruzada (entity_type/entity_id es polimorfico) que lo haga por cascada.
export async function removeAllForEntity(entityType, entityId) {
const files = await repo.findByEntity(entityType, entityId);
for (const file of files) await removeAttachment(file);
}
@@ -10,6 +10,12 @@ export async function findByEntity(entityType, entityId) {
return rows; return rows;
} }
// Usado al borrar la entidad duena (actividad, proyecto): limpia sus comentarios de paso, ya que
// no hay FK fisica cruzada (entity_type/entity_id es polimorfico) que lo haga por cascada.
export async function removeByEntity(entityType, entityId) {
await pool.query('DELETE FROM comments WHERE entity_type = ? AND entity_id = ?', [entityType, entityId]);
}
export async function create({ entityType, entityId, userId, message }) { export async function create({ entityType, entityId, userId, message }) {
const [result] = await pool.query( const [result] = await pool.query(
'INSERT INTO comments (entity_type, entity_id, user_id, message) VALUES (?, ?, ?, ?)', 'INSERT INTO comments (entity_type, entity_id, user_id, message) VALUES (?, ?, ?, ?)',
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "1.12.5", "version": "1.13.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -1,8 +1,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { Plus, Save } from 'lucide-react'; import { Plus, Save, 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 { listActivitiesByProject, createActivity, getActivity } from '../../services/activities.js'; import { listActivitiesByProject, createActivity, getActivity, deleteActivity } from '../../services/activities.js';
import { listCatalog } from '../../services/catalogs.js'; import { listCatalog } from '../../services/catalogs.js';
import { listUsers } from '../../services/users.js'; import { listUsers } from '../../services/users.js';
import { ListView } from '../../components/ListView.jsx'; import { ListView } from '../../components/ListView.jsx';
@@ -18,7 +19,8 @@ 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 }) { export default function ProjectActivitiesTab({ projectId }) {
const { t, pushToast } = useUi(); const { t, pushToast, openDialog } = useUi();
const { user: currentUser } = useAuth();
const runAction = useAction(); const runAction = useAction();
const { data: activities, reload } = useAsync(() => listActivitiesByProject(projectId), [projectId]); const { data: activities, reload } = useAsync(() => listActivitiesByProject(projectId), [projectId]);
const { data: priorities } = useAsync(() => listCatalog('priorities'), []); const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
@@ -55,6 +57,25 @@ export default function ProjectActivitiesTab({ projectId }) {
if (selected) setSelected(await getActivity(selected.id)); if (selected) setSelected(await getActivity(selected.id));
} }
function confirmDelete(row) {
openDialog({
tone: 'warning',
title: t('common_confirm_delete_title'),
message: t('common_confirm_delete_msg'),
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{
label: t('common_delete'), variant: 'danger',
onClick: () => runAction(async () => {
await deleteActivity(row.id);
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
}),
},
],
});
}
const columns = [ const columns = [
{ key: 'code', label: t('activity_code') }, { key: 'code', label: t('activity_code') },
{ key: 'name', label: t('common_name') }, { key: 'name', label: t('common_name') },
@@ -86,6 +107,9 @@ export default function ProjectActivitiesTab({ projectId }) {
getSearchHaystack={(a) => `${a.code} ${a.name} ${a.responsible_name || ''}`} getSearchHaystack={(a) => `${a.code} ${a.name} ${a.responsible_name || ''}`}
getRowKey={(a) => a.id} getRowKey={(a) => a.id}
onRowClick={openActivity} onRowClick={openActivity}
rowActions={currentUser?.roleCode !== 'viewer' ? (a) => [
{ label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(a) },
] : undefined}
/> />
<DetailPopup open={creating} title={t('activity_new')} onClose={() => setCreating(false)} width={640} height={560}> <DetailPopup open={creating} title={t('activity_new')} onClose={() => setCreating(false)} width={640} height={560}>
+1
View File
@@ -4,5 +4,6 @@ export const listActivitiesByProject = (projectId) => api.get(`/projects/${proje
export const createActivity = (projectId, data) => api.post(`/projects/${projectId}/activities`, data); export const createActivity = (projectId, data) => api.post(`/projects/${projectId}/activities`, data);
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 addDependency = (id, dependsOnActivityId) => api.post(`/activities/${id}/dependencies`, { dependsOnActivityId }); export const addDependency = (id, dependsOnActivityId) => api.post(`/activities/${id}/dependencies`, { dependsOnActivityId });
export const removeDependency = (id, dependsOnId) => api.delete(`/activities/${id}/dependencies/${dependsOnId}`); export const removeDependency = (id, dependsOnId) => api.delete(`/activities/${id}/dependencies/${dependsOnId}`);