feat: convertir una actividad en proyecto (con sus tareas)

Una tarea siempre necesita una actividad contenedora, asi que convertir crea DOS cosas: el
proyecto nuevo y, dentro, una actividad nueva que recibe las tareas -- y comentarios/adjuntos --
de la actividad original. Se piden ambos nombres (precargados con el nombre actual) antes de
confirmar. La actividad original se borra al final, ya vacia de tareas (no choca con el bloqueo
de removeActivity). Exige el mismo permiso que crear un proyecto, no solo escribir la actividad.

Nuevo POST /api/activities/:id/convert-to-project. Probado de punta a punta en dev: prioridad
heredada, tareas y comentario movidos correctamente, actividad original eliminada, navega al
proyecto nuevo al terminar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-17 08:59:16 -05:00
parent cc7928464b
commit 043433bf9b
15 changed files with 185 additions and 6 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "1.19.0",
"version": "1.20.0",
"private": true,
"type": "module",
"main": "src/server.js",
@@ -30,6 +30,10 @@ export const putMoveActivity = asyncHandler(async (req, res) => {
ok(res, await service.moveActivity(Number(req.params.id), Number(req.body.projectId), req.currentUser));
});
export const postConvertToProject = asyncHandler(async (req, res) => {
ok(res, await service.convertToProject(Number(req.params.id), req.body, req.currentUser), 201);
});
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);
+2 -1
View File
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { getActivity, putActivity, deleteActivity, putMoveActivity, postDependency, deleteDependency } from './controller.js';
import { getActivity, putActivity, deleteActivity, putMoveActivity, postConvertToProject, postDependency, deleteDependency } from './controller.js';
const router = Router();
@@ -9,6 +9,7 @@ router.get('/:id', getActivity);
router.put('/:id', putActivity);
router.delete('/:id', deleteActivity);
router.put('/:id/move', putMoveActivity);
router.post('/:id/convert-to-project', postConvertToProject);
router.post('/:id/dependencies', postDependency);
router.delete('/:id/dependencies/:dependsOnId', deleteDependency);
+56
View File
@@ -1,7 +1,9 @@
import * as repo from './repository.js';
import * as projectsRepo from '../projects/repository.js';
import * as commentsRepo from '../comments/repository.js';
import * as attachmentsRepo from '../attachments/repository.js';
import * as attachmentsService from '../attachments/service.js';
import * as tasksRepo from '../tasks/repository.js';
import { recalcProjectProgress, canWriteProject } from '../projects/service.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
@@ -144,6 +146,60 @@ export async function moveActivity(id, targetProjectId, currentUser) {
return moved;
}
// Convierte una actividad en un proyecto nuevo. Una tarea no puede vivir directo dentro de un
// proyecto (siempre necesita una actividad contenedora), asi que esto crea DOS cosas: el proyecto
// nuevo, y una actividad nueva adentro que recibe las tareas/comentarios/adjuntos de la original
// (la original se borra al final, ya vacia de tareas). Exige el mismo permiso que crear un
// proyecto (no solo escribir la actividad) — convertir equivale a crear un proyecto de la nada.
export async function convertToProject(activityId, { projectName, taskActivityName }, currentUser) {
const activity = await repo.findById(activityId);
if (!activity) throw new HttpError(404, 'Actividad no encontrada');
const sourceProject = await getProjectOr404(activity.project_id);
if (!(await canWriteActivity(currentUser, sourceProject, activity))) {
throw new HttpError(403, 'No tienes permisos sobre esta actividad');
}
if (![ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD].includes(currentUser.roleCode)) {
throw new HttpError(403, 'No tienes permisos para crear proyectos');
}
const name = (projectName || '').trim();
const taskActName = (taskActivityName || '').trim();
if (!name) throw new HttpError(400, 'El nombre del proyecto es obligatorio');
if (!taskActName) throw new HttpError(400, 'El nombre de la actividad es obligatorio');
const defaultStatusId = await projectsRepo.getDefaultStatusId();
if (!defaultStatusId) throw new HttpError(500, 'No hay catalogo de estados de proyecto configurado');
const newProject = await projectsRepo.create({
name, priorityId: activity.priority_id, statusId: defaultStatusId,
ownerUserId: currentUser.id, createdBy: currentUser.id,
});
const newActivity = await repo.create(newProject.id, {
name: taskActName, priorityId: activity.priority_id, statusId: activity.status_id,
createdBy: currentUser.id,
});
await tasksRepo.moveAll(activityId, newActivity.id);
await commentsRepo.reassignEntity('activity', activityId, newActivity.id);
await attachmentsRepo.reassignEntity('activity', activityId, newActivity.id);
const counts = await tasksRepo.countsByActivity(newActivity.id);
await recalcFromTasks(newActivity.id, counts.done, counts.total);
await repo.remove(activityId); // ya sin tareas: el bloqueo de removeActivity no aplica aqui.
await logAudit({
userId: currentUser.id, action: 'convert_to_project', entityType: 'activity', entityId: activityId,
detail: { newProjectId: newProject.id, newActivityId: newActivity.id },
});
await recalcProjectProgress(activity.project_id);
await recalcProjectProgress(newProject.id);
return { project: newProject, activity: newActivity };
}
// Nuevo orden manual (arrastrar con el mouse): mismo permiso que crear actividades en el proyecto.
export async function reorderActivities(projectId, ids, currentUser) {
const project = await getProjectOr404(projectId);
@@ -28,3 +28,9 @@ export async function create({ entityType, entityId, filename, originalName, mim
export async function remove(id) {
await pool.query('DELETE FROM attachments WHERE id = ?', [id]);
}
// Usado al convertir una actividad en proyecto: sus adjuntos pasan a la actividad nueva que
// hereda sus tareas, en vez de perderse cuando se borra la actividad original.
export async function reassignEntity(entityType, oldEntityId, newEntityId) {
await pool.query('UPDATE attachments SET entity_id = ? WHERE entity_type = ? AND entity_id = ?', [newEntityId, entityType, oldEntityId]);
}
@@ -16,6 +16,12 @@ export async function removeByEntity(entityType, entityId) {
await pool.query('DELETE FROM comments WHERE entity_type = ? AND entity_id = ?', [entityType, entityId]);
}
// Usado al convertir una actividad en proyecto: sus comentarios pasan a la actividad nueva que
// hereda sus tareas, en vez de perderse cuando se borra la actividad original.
export async function reassignEntity(entityType, oldEntityId, newEntityId) {
await pool.query('UPDATE comments SET entity_id = ? WHERE entity_type = ? AND entity_id = ?', [newEntityId, entityType, oldEntityId]);
}
export async function create({ entityType, entityId, userId, message }) {
const [result] = await pool.query(
'INSERT INTO comments (entity_type, entity_id, user_id, message) VALUES (?, ?, ?, ?)',
@@ -72,6 +72,13 @@ export async function create(data) {
return findById(id);
}
// Usado al crear un proyecto sin elegir estado explicito (ej. al convertir una actividad en
// proyecto): el primer estado no-cerrado por orden manual, tipicamente "Planificado".
export async function getDefaultStatusId() {
const [rows] = await pool.query('SELECT id FROM project_statuses WHERE is_closed = 0 ORDER BY sort_order LIMIT 1');
return rows[0]?.id || null;
}
export async function update(id, data) {
await pool.query(
`UPDATE projects SET
+6
View File
@@ -48,6 +48,12 @@ export async function remove(id) {
await pool.query('DELETE FROM tasks WHERE id = ?', [id]);
}
// Usado al convertir una actividad en proyecto: sus tareas pasan a la actividad nueva que se crea
// para contenerlas. sort_order vuelve a 0 ("nunca reordenado a mano") en el destino.
export async function moveAll(oldActivityId, newActivityId) {
await pool.query('UPDATE tasks SET activity_id = ?, sort_order = 0 WHERE activity_id = ?', [newActivityId, oldActivityId]);
}
export async function countsByActivity(activityId) {
const [rows] = await pool.query(
'SELECT COUNT(*) AS total, SUM(is_done) AS done FROM tasks WHERE activity_id = ?',