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:
@@ -71,6 +71,9 @@
|
||||
### Actividades (`/api/activities`)
|
||||
- `GET /api/projects/:projectId/activities` · `POST /api/projects/:projectId/activities`
|
||||
- `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`
|
||||
|
||||
### Tareas (`/api/activities/:activityId/tasks`)
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 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
|
||||
### Changed
|
||||
- Listado de Proyectos: columna "Creado" reemplazada por "Actualizado" (fecha y hora), y es ahora
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "1.12.5",
|
||||
"version": "1.13.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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));
|
||||
});
|
||||
|
||||
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) => {
|
||||
const deps = await service.addDependency(Number(req.params.id), Number(req.body.dependsOnActivityId), req.currentUser);
|
||||
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]);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS c FROM tasks WHERE activity_id = ?', [id]);
|
||||
return rows[0].c;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
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();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/:id', getActivity);
|
||||
router.put('/:id', putActivity);
|
||||
router.delete('/:id', deleteActivity);
|
||||
router.post('/:id/dependencies', postDependency);
|
||||
router.delete('/:id/dependencies/:dependsOnId', deleteDependency);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as repo from './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 { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
@@ -95,6 +97,27 @@ export async function updateActivity(id, payload, currentUser) {
|
||||
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) {
|
||||
if (activityId === dependsOnActivityId) throw new HttpError(400, 'Una actividad no puede depender de si misma');
|
||||
const activity = await repo.findById(activityId);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { logAudit } from '../../utils/audit.js';
|
||||
import { ROLES } from '../../middleware/auth.js';
|
||||
import { uploadsDir } from '../../middleware/upload.js';
|
||||
import * as repo from './repository.js';
|
||||
import { removeAttachment } from './service.js';
|
||||
|
||||
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;
|
||||
if (!canDelete) throw new HttpError(403, 'No puedes eliminar este adjunto');
|
||||
|
||||
await repo.remove(attachment.id);
|
||||
const filePath = path.join(uploadsDir, attachment.filename);
|
||||
fs.promises.unlink(filePath).catch(() => {});
|
||||
await removeAttachment(attachment);
|
||||
await logAudit({ userId: req.currentUser.id, action: 'delete', entityType: attachment.entity_type, entityId: attachment.entity_id, detail: { attachmentId: attachment.id } });
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 }) {
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO comments (entity_type, entity_id, user_id, message) VALUES (?, ?, ?, ?)',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "1.12.5",
|
||||
"version": "1.13.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { useAuth } from '../../context/auth-context.jsx';
|
||||
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 { listUsers } from '../../services/users.js';
|
||||
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 };
|
||||
|
||||
export default function ProjectActivitiesTab({ projectId }) {
|
||||
const { t, pushToast } = useUi();
|
||||
const { t, pushToast, openDialog } = useUi();
|
||||
const { user: currentUser } = useAuth();
|
||||
const runAction = useAction();
|
||||
const { data: activities, reload } = useAsync(() => listActivitiesByProject(projectId), [projectId]);
|
||||
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
||||
@@ -55,6 +57,25 @@ export default function ProjectActivitiesTab({ projectId }) {
|
||||
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 = [
|
||||
{ key: 'code', label: t('activity_code') },
|
||||
{ key: 'name', label: t('common_name') },
|
||||
@@ -86,6 +107,9 @@ export default function ProjectActivitiesTab({ projectId }) {
|
||||
getSearchHaystack={(a) => `${a.code} ${a.name} ${a.responsible_name || ''}`}
|
||||
getRowKey={(a) => a.id}
|
||||
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}>
|
||||
|
||||
@@ -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 getActivity = (id) => api.get(`/activities/${id}`);
|
||||
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 removeDependency = (id, dependsOnId) => api.delete(`/activities/${id}/dependencies/${dependsOnId}`);
|
||||
|
||||
Reference in New Issue
Block a user