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:
@@ -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 (?, ?, ?, ?)',
|
||||
|
||||
Reference in New Issue
Block a user