Files
project.linkedcorp.net/backend/src/modules/activities/service.js
T
Milton Gonzalez 7557dffc52 feat: autorregistro con verificacion de correo + aprobacion, superadmin y SMTP
- POST /api/auth/register: primer usuario del sistema -> superadmin con sesion
  directa (bootstrap, sin correo ni aprobacion); los siguientes quedan
  pending_verification y reciben un correo con enlace de un solo uso (24h).
- GET /api/auth/verify-email: pasa a pending_approval y notifica in-app a los
  admin/superadmin activos.
- admin/superadmin aprueban (POST /api/users/:id/approve, eligen rol y
  departamento) o rechazan (POST /api/users/:id/reject, borra y audita el
  email) desde Organizacion -> Usuarios (nuevo badge de estado + acciones).
- login() da mensaje especifico segun el status de la cuenta en vez del
  generico "credenciales invalidas".
- Recuperacion de clave (forgot/reset-password): respuesta siempre generica,
  token de 1 hora de un solo uso.
- Nuevo modulo settings: SMTP configurable SOLO por superadmin desde
  Configuracion -> Correo (SMTP); clave cifrada AES-256-GCM (ENCRYPTION_KEY)
  en la tabla system_settings, nunca expuesta por la API. sendMail() degrada
  a modo dev (loguea en vez de enviar) si no hay SMTP configurado, y nunca
  deja que un fallo de envio tumbe al que lo llama.
- Migracion 002_registration_and_smtp.sql: rol superadmin, columnas de ciclo
  de vida en users (status, tokens), tabla system_settings, y promueve al
  usuario mas antiguo del sistema a superadmin.
- Fix: React.StrictMode disparaba el efecto de VerifyEmailPage dos veces; la
  segunda llamada (token ya consumido) mostraba "enlace invalido" pese a que
  la verificacion ya habia funcionado. Se agrega una guarda por token.
- Fix (deuda tecnica v1.0.0): se reordena el montaje de rutas en app.js —
  subtasksRoutes (mount generico en /api) va al final, para no interceptar
  con su requireAuth interno una request sin token dirigida a un router mas
  especifico registrado despues.
- Documentacion actualizada: AGENTS.md, SPEC.md, API.md, BASE_DE_DATOS.md,
  CONFIGURACION.md, ESTADO.md, CHANGELOG.md. Version 1.1.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 23:28:46 -05:00

131 lines
6.1 KiB
JavaScript

import * as repo from './repository.js';
import * as projectsRepo from '../projects/repository.js';
import { recalcProjectProgress, canWriteProject } from '../projects/service.js';
import { HttpError } from '../../utils/http.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;
}
// admin/it_manager siempre; project_lead si tiene acceso al proyecto; collaborator solo si es
// el responsable de la actividad (no puede crear actividades nuevas ni reasignarlas a otros).
export async function canWriteActivity(user, project, activity) {
if ([ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(user.roleCode)) return true;
if (user.roleCode === ROLES.PROJECT_LEAD) return canWriteProject(user, project);
if (user.roleCode === ROLES.COLLABORATOR && activity) return activity.responsible_user_id === user.id;
return false;
}
export const listByProject = (projectId) => repo.findByProject(projectId);
export async function getActivity(id) {
const activity = await repo.findById(id);
if (!activity) 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 subtareas.
const nextResponsible = currentUser.roleCode === ROLES.COLLABORATOR
? activity.responsible_user_id
: (payload.responsibleUserId ?? activity.responsible_user_id);
const subtaskCount = await repo.countSubtasks(id);
const updated = await repo.update(id, { ...payload, responsibleUserId: nextResponsible });
if (subtaskCount === 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);
}
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 subtareas (si tiene) y propaga al proyecto.
export async function recalcFromSubtasks(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);
}