e65111a7f4
Permisos (superadmin, viewer, busqueda, tareas recurrentes), bootstrap atomico, revocacion de JWT por version de sesion (028), control optimista de edicion (029), logo sin SVG + CSP, ingesta endurecida, correos escapados, borradores, paginacion de proyectos, accesibilidad de dialogos/controles e i18n. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
268 lines
13 KiB
JavaScript
268 lines
13 KiB
JavaScript
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 tasksRepo from '../tasks/repository.js';
|
|
import { recalcProjectProgress, canReadProject, canWriteProject } from '../projects/service.js';
|
|
import { HttpError } from '../../utils/http.js';
|
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
|
import { logAudit } from '../../utils/audit.js';
|
|
import { ROLES } from '../../middleware/auth.js';
|
|
import { createNotification } from '../notifications/service.js';
|
|
|
|
async function getProjectOr404(projectId) {
|
|
const project = await projectsRepo.findById(projectId);
|
|
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
|
|
return project;
|
|
}
|
|
|
|
// Se delega entero a canWriteProject: con el modelo de compartir por proyecto (Lectura/Escritura,
|
|
// ver projects/service.js), quien tiene Escritura puede crear/editar/eliminar cualquier
|
|
// actividad o tarea del proyecto -- ya no importa si es el "responsable" asignado o no (esa
|
|
// regla vieja, previa a tener niveles de permiso reales por proyecto, quedo obsoleta).
|
|
export async function canWriteActivity(user, project) {
|
|
return canWriteProject(user, project);
|
|
}
|
|
|
|
export async function listByProject(projectId, currentUser) {
|
|
const project = await getProjectOr404(projectId);
|
|
if (!(await canReadProject(currentUser, project))) throw new HttpError(404, 'Proyecto no encontrado');
|
|
return repo.findByProject(projectId);
|
|
}
|
|
|
|
export async function listTrash(projectId, currentUser) {
|
|
const project = await getProjectOr404(projectId);
|
|
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
|
|
return repo.findTrashByProject(projectId);
|
|
}
|
|
|
|
export async function getActivity(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 canReadProject(currentUser, project))) throw new HttpError(404, 'Actividad no encontrada');
|
|
const dependencies = await repo.listDependencies(id);
|
|
return { ...activity, dependencies };
|
|
}
|
|
|
|
export async function createActivity(projectId, payload, currentUser) {
|
|
const project = await getProjectOr404(projectId);
|
|
if (!(await canWriteProject(currentUser, project))) {
|
|
throw new HttpError(403, 'No tienes permisos para crear actividades en este proyecto');
|
|
}
|
|
if (!payload.name || !payload.priorityId || !payload.statusId) {
|
|
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
|
|
}
|
|
const activity = await repo.create(projectId, { ...payload, createdBy: currentUser.id });
|
|
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'activity', entityId: activity.id });
|
|
|
|
if (activity.responsible_user_id && activity.responsible_user_id !== currentUser.id) {
|
|
await createNotification({
|
|
userId: activity.responsible_user_id,
|
|
type: 'activity_assigned',
|
|
message: `Te asignaron la actividad "${activity.name}"`,
|
|
entityType: 'activity',
|
|
entityId: activity.id,
|
|
});
|
|
}
|
|
await recalcProjectProgress(projectId);
|
|
return activity;
|
|
}
|
|
|
|
export async function updateActivity(id, payload, 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');
|
|
}
|
|
if (!payload.name || !payload.priorityId || !payload.statusId) {
|
|
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
|
|
}
|
|
|
|
// Colaborador no puede reasignar la actividad a otra persona ni cambiar avance manual si hay tareas.
|
|
const nextResponsible = currentUser.roleCode === ROLES.COLLABORATOR
|
|
? activity.responsible_user_id
|
|
: (payload.responsibleUserId ?? activity.responsible_user_id);
|
|
|
|
requireEditVersion(payload.editVersion);
|
|
const taskCount = await repo.countTasks(id);
|
|
const updated = requireUpdated(await repo.update(id, { ...payload, responsibleUserId: nextResponsible }));
|
|
|
|
if (taskCount === 0 && payload.progressPct !== undefined) {
|
|
const pct = Math.max(0, Math.min(100, Number(payload.progressPct)));
|
|
await repo.updateProgress(id, pct);
|
|
updated.progress_pct = pct;
|
|
}
|
|
|
|
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'activity', entityId: id });
|
|
|
|
if (nextResponsible && nextResponsible !== activity.responsible_user_id && nextResponsible !== currentUser.id) {
|
|
await createNotification({
|
|
userId: nextResponsible,
|
|
type: 'activity_assigned',
|
|
message: `Te asignaron la actividad "${updated.name}"`,
|
|
entityType: 'activity',
|
|
entityId: id,
|
|
});
|
|
}
|
|
|
|
await recalcProjectProgress(activity.project_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. Es eliminacion logica (recuperable, ver restoreActivity) -- comentarios y
|
|
// adjuntos NO se tocan, quedan intactos por si se restaura despues.
|
|
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 repo.softDelete(id, currentUser.id);
|
|
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'activity', entityId: id });
|
|
await recalcProjectProgress(activity.project_id);
|
|
}
|
|
|
|
export async function restoreActivity(id, currentUser) {
|
|
const activity = await repo.findById(id, { includeDeleted: true });
|
|
if (!activity || !activity.deleted_at) throw new HttpError(404, 'Actividad no encontrada en la papelera');
|
|
const project = await getProjectOr404(activity.project_id);
|
|
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
|
|
await repo.restore(id);
|
|
await logAudit({ userId: currentUser.id, action: 'restore', entityType: 'activity', entityId: id });
|
|
await recalcProjectProgress(activity.project_id);
|
|
}
|
|
|
|
// Mueve la actividad (y sus tareas/comentarios/adjuntos, que van solos por referenciar activity_id
|
|
// en vez de project_id) a otro proyecto. Exige permiso de escritura en AMBOS proyectos.
|
|
export async function moveActivity(id, targetProjectId, currentUser) {
|
|
const activity = await repo.findById(id);
|
|
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 (Number(targetProjectId) === activity.project_id) {
|
|
throw new HttpError(400, 'La actividad ya pertenece a este proyecto');
|
|
}
|
|
const targetProject = await getProjectOr404(targetProjectId);
|
|
if (!(await canWriteProject(currentUser, targetProject))) {
|
|
throw new HttpError(403, 'No tienes permisos sobre el proyecto de destino');
|
|
}
|
|
const moved = await repo.moveToProject(id, targetProjectId);
|
|
await logAudit({
|
|
userId: currentUser.id, action: 'move', entityType: 'activity', entityId: id,
|
|
detail: { fromProjectId: activity.project_id, toProjectId: Number(targetProjectId) },
|
|
});
|
|
await recalcProjectProgress(activity.project_id);
|
|
await recalcProjectProgress(targetProjectId);
|
|
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);
|
|
if (!(await canWriteProject(currentUser, project))) {
|
|
throw new HttpError(403, 'No tienes permisos sobre este proyecto');
|
|
}
|
|
if (!Array.isArray(ids) || ids.length === 0) throw new HttpError(400, 'Falta el orden de actividades');
|
|
await repo.reorder(projectId, ids);
|
|
return repo.findByProject(projectId);
|
|
}
|
|
|
|
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);
|
|
if (!activity) throw new HttpError(404, 'Actividad no encontrada');
|
|
const dependsOn = await repo.findById(dependsOnActivityId);
|
|
if (!dependsOn || dependsOn.project_id !== activity.project_id) {
|
|
throw new HttpError(400, 'La dependencia debe ser una actividad del mismo proyecto');
|
|
}
|
|
const project = await getProjectOr404(activity.project_id);
|
|
if (!(await canWriteActivity(currentUser, project, activity))) throw new HttpError(403, 'No tienes permisos sobre esta actividad');
|
|
const deps = await repo.addDependency(activityId, dependsOnActivityId);
|
|
await logAudit({ userId: currentUser.id, action: 'add_dependency', entityType: 'activity', entityId: activityId, detail: { dependsOnActivityId } });
|
|
return deps;
|
|
}
|
|
|
|
export async function removeDependency(activityId, dependsOnActivityId, currentUser) {
|
|
const activity = await repo.findById(activityId);
|
|
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 deps = await repo.removeDependency(activityId, dependsOnActivityId);
|
|
await logAudit({ userId: currentUser.id, action: 'remove_dependency', entityType: 'activity', entityId: activityId, detail: { dependsOnActivityId } });
|
|
return deps;
|
|
}
|
|
|
|
// Recalcula el avance de una actividad a partir de sus tareas (si tiene) y propaga al proyecto.
|
|
export async function recalcFromTasks(activityId, doneCount, totalCount) {
|
|
if (totalCount === 0) return;
|
|
const pct = Math.round((doneCount / totalCount) * 10000) / 100;
|
|
await repo.updateProgress(activityId, pct);
|
|
const activity = await repo.findById(activityId);
|
|
await recalcProjectProgress(activity.project_id);
|
|
}
|