chore: estructura inicial del MVP
Backend (Express + MariaDB) y frontend (React + Vite, identidad Fluent/Windows) con el nucleo funcional: seguridad, organizacion, proyectos, actividades, subtareas, dependencias, dashboard, calendario, gantt, comentarios, adjuntos, notificaciones in-app y catalogos. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as service from './service.js';
|
||||
|
||||
export const getActivitiesByProject = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.listByProject(Number(req.params.projectId)));
|
||||
});
|
||||
|
||||
export const postActivity = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.createActivity(Number(req.params.projectId), req.body, req.currentUser), 201);
|
||||
});
|
||||
|
||||
export const getActivity = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.getActivity(Number(req.params.id)));
|
||||
});
|
||||
|
||||
export const putActivity = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.updateActivity(Number(req.params.id), req.body, req.currentUser));
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
export const deleteDependency = asyncHandler(async (req, res) => {
|
||||
const deps = await service.removeDependency(Number(req.params.id), Number(req.params.dependsOnId), req.currentUser);
|
||||
ok(res, deps);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
const LIST_SELECT = `
|
||||
SELECT a.id, a.project_id, a.code, a.name, a.description, a.responsible_user_id, u.name AS responsible_name,
|
||||
a.priority_id, pr.name AS priority_name, pr.color AS priority_color,
|
||||
a.status_id, st.name AS status_name, st.color AS status_color, st.is_closed AS status_is_closed,
|
||||
a.start_date, a.due_date, a.actual_end_date, a.progress_pct, a.is_milestone,
|
||||
a.estimated_hours, a.created_by, a.created_at, a.updated_at
|
||||
FROM activities a
|
||||
JOIN priorities pr ON pr.id = a.priority_id
|
||||
JOIN activity_statuses st ON st.id = a.status_id
|
||||
LEFT JOIN users u ON u.id = a.responsible_user_id
|
||||
`;
|
||||
|
||||
export async function findByProject(projectId) {
|
||||
const [rows] = await pool.query(`${LIST_SELECT} WHERE a.project_id = ? ORDER BY a.start_date, a.id`, [projectId]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function findById(id) {
|
||||
const [rows] = await pool.query(`${LIST_SELECT} WHERE a.id = ?`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function create(projectId, data) {
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO activities
|
||||
(project_id, code, name, description, responsible_user_id, priority_id, status_id,
|
||||
start_date, due_date, is_milestone, estimated_hours, created_by)
|
||||
VALUES (?, 'PENDING', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
projectId, data.name, data.description || null, data.responsibleUserId || null,
|
||||
data.priorityId, data.statusId, data.startDate || null, data.dueDate || null,
|
||||
data.isMilestone ? 1 : 0, data.estimatedHours || null, data.createdBy,
|
||||
]
|
||||
);
|
||||
const id = result.insertId;
|
||||
const code = `ACT-${String(id).padStart(4, '0')}`;
|
||||
await pool.query('UPDATE activities SET code = ? WHERE id = ?', [code, id]);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function update(id, data) {
|
||||
await pool.query(
|
||||
`UPDATE activities SET
|
||||
name = ?, description = ?, responsible_user_id = ?, priority_id = ?, status_id = ?,
|
||||
start_date = ?, due_date = ?, actual_end_date = ?, is_milestone = ?, estimated_hours = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
data.name, data.description || null, data.responsibleUserId || null, data.priorityId, data.statusId,
|
||||
data.startDate || null, data.dueDate || null, data.actualEndDate || null,
|
||||
data.isMilestone ? 1 : 0, data.estimatedHours || null, id,
|
||||
]
|
||||
);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function updateProgress(id, progressPct) {
|
||||
await pool.query('UPDATE activities SET progress_pct = ? WHERE id = ?', [progressPct, id]);
|
||||
}
|
||||
|
||||
export async function countSubtasks(id) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS c FROM subtasks WHERE activity_id = ?', [id]);
|
||||
return rows[0].c;
|
||||
}
|
||||
|
||||
export async function listDependencies(id) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT d.depends_on_activity_id AS id, a.code, a.name, a.status_id, st.is_closed AS status_is_closed
|
||||
FROM activity_dependencies d
|
||||
JOIN activities a ON a.id = d.depends_on_activity_id
|
||||
JOIN activity_statuses st ON st.id = a.status_id
|
||||
WHERE d.activity_id = ?`,
|
||||
[id]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function addDependency(activityId, dependsOnActivityId) {
|
||||
await pool.query(
|
||||
'INSERT IGNORE INTO activity_dependencies (activity_id, depends_on_activity_id) VALUES (?, ?)',
|
||||
[activityId, dependsOnActivityId]
|
||||
);
|
||||
return listDependencies(activityId);
|
||||
}
|
||||
|
||||
export async function removeDependency(activityId, dependsOnActivityId) {
|
||||
await pool.query('DELETE FROM activity_dependencies WHERE activity_id = ? AND depends_on_activity_id = ?', [
|
||||
activityId, dependsOnActivityId,
|
||||
]);
|
||||
return listDependencies(activityId);
|
||||
}
|
||||
|
||||
export async function findDueOrOverdue(userId, { onlyMine = false } = {}) {
|
||||
const clauses = ["st.is_closed = 0", "a.due_date IS NOT NULL"];
|
||||
const params = [];
|
||||
if (onlyMine) { clauses.push('a.responsible_user_id = ?'); params.push(userId); }
|
||||
const [rows] = await pool.query(
|
||||
`${LIST_SELECT} WHERE ${clauses.join(' AND ')} ORDER BY a.due_date`,
|
||||
params
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { getActivity, putActivity, postDependency, deleteDependency } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/:id', getActivity);
|
||||
router.put('/:id', putActivity);
|
||||
router.post('/:id/dependencies', postDependency);
|
||||
router.delete('/:id/dependencies/:dependsOnId', deleteDependency);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,130 @@
|
||||
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 (user.roleCode === ROLES.ADMIN || user.roleCode === ROLES.IT_MANAGER) 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);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { ok, fail, asyncHandler, HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
import { ROLES } from '../../middleware/auth.js';
|
||||
import { uploadsDir } from '../../middleware/upload.js';
|
||||
import * as repo from './repository.js';
|
||||
|
||||
const VALID_ENTITY_TYPES = new Set(['project', 'activity']);
|
||||
|
||||
export const getAttachments = asyncHandler(async (req, res) => {
|
||||
const { entityType, entityId } = req.query;
|
||||
if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) return fail(res, 400, 'entityType y entityId son obligatorios');
|
||||
ok(res, await repo.findByEntity(entityType, Number(entityId)));
|
||||
});
|
||||
|
||||
export const postAttachment = asyncHandler(async (req, res) => {
|
||||
const { entityType, entityId } = req.body || {};
|
||||
if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) throw new HttpError(400, 'entityType y entityId son obligatorios');
|
||||
if (req.currentUser.roleCode === ROLES.VIEWER) throw new HttpError(403, 'Tu rol es de solo lectura');
|
||||
if (!req.file) throw new HttpError(400, 'Archivo obligatorio');
|
||||
|
||||
const attachment = await repo.create({
|
||||
entityType, entityId: Number(entityId),
|
||||
filename: req.file.filename, originalName: req.file.originalname,
|
||||
mimeType: req.file.mimetype, size: req.file.size, uploadedBy: req.currentUser.id,
|
||||
});
|
||||
await logAudit({ userId: req.currentUser.id, action: 'upload', entityType, entityId: Number(entityId), detail: { attachmentId: attachment.id } });
|
||||
ok(res, attachment, 201);
|
||||
});
|
||||
|
||||
export const downloadAttachment = asyncHandler(async (req, res) => {
|
||||
const attachment = await repo.findById(Number(req.params.id));
|
||||
if (!attachment) throw new HttpError(404, 'Adjunto no encontrado');
|
||||
const filePath = path.join(uploadsDir, attachment.filename);
|
||||
if (!fs.existsSync(filePath)) throw new HttpError(404, 'Archivo no encontrado en el servidor');
|
||||
res.download(filePath, attachment.original_name);
|
||||
});
|
||||
|
||||
export const deleteAttachment = asyncHandler(async (req, res) => {
|
||||
const attachment = await repo.findById(Number(req.params.id));
|
||||
if (!attachment) throw new HttpError(404, 'Adjunto no encontrado');
|
||||
const canDelete = [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 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,30 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function findByEntity(entityType, entityId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT a.id, a.entity_type, a.entity_id, a.filename, a.original_name, a.mime_type, a.size,
|
||||
a.uploaded_by, u.name AS uploaded_by_name, a.created_at
|
||||
FROM attachments a JOIN users u ON u.id = a.uploaded_by
|
||||
WHERE a.entity_type = ? AND a.entity_id = ? ORDER BY a.created_at DESC`,
|
||||
[entityType, entityId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function findById(id) {
|
||||
const [rows] = await pool.query('SELECT * FROM attachments WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function create({ entityType, entityId, filename, originalName, mimeType, size, uploadedBy }) {
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO attachments (entity_type, entity_id, filename, original_name, mime_type, size, uploaded_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[entityType, entityId, filename, originalName, mimeType, size, uploadedBy]
|
||||
);
|
||||
return findById(result.insertId);
|
||||
}
|
||||
|
||||
export async function remove(id) {
|
||||
await pool.query('DELETE FROM attachments WHERE id = ?', [id]);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { upload } from '../../middleware/upload.js';
|
||||
import { getAttachments, postAttachment, downloadAttachment, deleteAttachment } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/', getAttachments);
|
||||
router.post('/', upload.single('file'), postAttachment);
|
||||
router.get('/:id/download', downloadAttachment);
|
||||
router.delete('/:id', deleteAttachment);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as repo from './repository.js';
|
||||
|
||||
export const getAudit = asyncHandler(async (req, res) => {
|
||||
const { entityType, entityId } = req.query;
|
||||
ok(res, await repo.findAll({ entityType, entityId: entityId ? Number(entityId) : undefined }));
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function findAll({ entityType, entityId } = {}) {
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
if (entityType) { clauses.push('a.entity_type = ?'); params.push(entityType); }
|
||||
if (entityId) { clauses.push('a.entity_id = ?'); params.push(entityId); }
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const [rows] = await pool.query(
|
||||
`SELECT a.id, a.user_id, u.name AS user_name, a.action, a.entity_type, a.entity_id, a.detail_json, a.created_at
|
||||
FROM audit_log a LEFT JOIN users u ON u.id = a.user_id
|
||||
${where} ORDER BY a.created_at DESC LIMIT 200`,
|
||||
params
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js';
|
||||
import { getAudit } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth, requireRole(ROLES.ADMIN, ROLES.IT_MANAGER));
|
||||
router.get('/', getAudit);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ok, fail, asyncHandler } from '../../utils/http.js';
|
||||
import * as authService from './service.js';
|
||||
|
||||
export const postLogin = asyncHandler(async (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!email || !password) return fail(res, 400, 'Email y clave son obligatorios');
|
||||
const result = await authService.login(email, password);
|
||||
ok(res, result);
|
||||
});
|
||||
|
||||
export const postLogout = asyncHandler(async (req, res) => {
|
||||
// Stateless JWT: el cliente descarta el token. Se deja el endpoint para uso futuro (blacklist).
|
||||
ok(res, { loggedOut: true });
|
||||
});
|
||||
|
||||
export const getMe = asyncHandler(async (req, res) => {
|
||||
ok(res, req.currentUser);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { postLogin, postLogout, getMe } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
limit: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
router.post('/login', loginLimiter, postLogin);
|
||||
router.post('/logout', requireAuth, postLogout);
|
||||
router.get('/me', requireAuth, getMe);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,36 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { pool } from '../../config/db.js';
|
||||
import { env } from '../../config/env.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
|
||||
export async function login(email, password) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.department_id, r.code AS role_code, r.name AS role_name
|
||||
FROM users u JOIN roles r ON r.id = u.role_id
|
||||
WHERE u.email = ? LIMIT 1`,
|
||||
[email]
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user || !user.is_active) throw new HttpError(401, 'Credenciales invalidas');
|
||||
|
||||
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||||
if (!validPassword) throw new HttpError(401, 'Credenciales invalidas');
|
||||
|
||||
const token = jwt.sign({ sub: user.id }, env.jwtSecret, { expiresIn: env.jwtExpires });
|
||||
|
||||
await logAudit({ userId: user.id, action: 'login', entityType: 'auth', entityId: user.id });
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
roleCode: user.role_code,
|
||||
roleName: user.role_name,
|
||||
departmentId: user.department_id,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ok, fail, asyncHandler } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
import { CATALOGS } from './repository.js';
|
||||
import * as repo from './repository.js';
|
||||
|
||||
function validKey(req, res) {
|
||||
const key = req.params.catalog;
|
||||
if (!CATALOGS[key]) {
|
||||
fail(res, 404, 'Catalogo no encontrado');
|
||||
return null;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export const getCatalog = asyncHandler(async (req, res) => {
|
||||
const key = validKey(req, res);
|
||||
if (!key) return;
|
||||
ok(res, await repo.findAll(key));
|
||||
});
|
||||
|
||||
export const postCatalog = asyncHandler(async (req, res) => {
|
||||
const key = validKey(req, res);
|
||||
if (!key) return;
|
||||
const item = await repo.create(key, req.body);
|
||||
await logAudit({ userId: req.currentUser.id, action: 'create', entityType: `catalog:${key}`, entityId: item.id });
|
||||
ok(res, item, 201);
|
||||
});
|
||||
|
||||
export const putCatalog = asyncHandler(async (req, res) => {
|
||||
const key = validKey(req, res);
|
||||
if (!key) return;
|
||||
const item = await repo.update(key, Number(req.params.id), req.body);
|
||||
await logAudit({ userId: req.currentUser.id, action: 'update', entityType: `catalog:${key}`, entityId: item.id });
|
||||
ok(res, item);
|
||||
});
|
||||
|
||||
export const deleteCatalog = asyncHandler(async (req, res) => {
|
||||
const key = validKey(req, res);
|
||||
if (!key) return;
|
||||
await repo.remove(key, Number(req.params.id));
|
||||
await logAudit({ userId: req.currentUser.id, action: 'delete', entityType: `catalog:${key}`, entityId: Number(req.params.id) });
|
||||
ok(res, { deleted: true });
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
// Whitelist de catalogos editables — nunca aceptar el nombre de tabla desde el cliente.
|
||||
export const CATALOGS = {
|
||||
'project-types': { table: 'project_types', fields: ['name'], order: 'name' },
|
||||
priorities: { table: 'priorities', fields: ['name', 'level', 'color'], order: 'level' },
|
||||
'project-statuses': {
|
||||
table: 'project_statuses',
|
||||
fields: ['name', 'sort_order', 'color', 'is_closed'],
|
||||
order: 'sort_order',
|
||||
},
|
||||
'activity-statuses': {
|
||||
table: 'activity_statuses',
|
||||
fields: ['name', 'sort_order', 'color', 'is_closed'],
|
||||
order: 'sort_order',
|
||||
},
|
||||
};
|
||||
|
||||
export async function findAll(catalogKey) {
|
||||
const cfg = CATALOGS[catalogKey];
|
||||
const [rows] = await pool.query(`SELECT * FROM ${cfg.table} ORDER BY ${cfg.order}`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function create(catalogKey, payload) {
|
||||
const cfg = CATALOGS[catalogKey];
|
||||
const cols = cfg.fields.filter((f) => payload[f] !== undefined);
|
||||
const placeholders = cols.map(() => '?').join(', ');
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO ${cfg.table} (${cols.join(', ')}) VALUES (${placeholders})`,
|
||||
cols.map((c) => payload[c])
|
||||
);
|
||||
return { id: result.insertId, ...payload };
|
||||
}
|
||||
|
||||
export async function update(catalogKey, id, payload) {
|
||||
const cfg = CATALOGS[catalogKey];
|
||||
const cols = cfg.fields.filter((f) => payload[f] !== undefined);
|
||||
const setClause = cols.map((c) => `${c} = ?`).join(', ');
|
||||
await pool.query(`UPDATE ${cfg.table} SET ${setClause} WHERE id = ?`, [...cols.map((c) => payload[c]), id]);
|
||||
return { id, ...payload };
|
||||
}
|
||||
|
||||
export async function remove(catalogKey, id) {
|
||||
const cfg = CATALOGS[catalogKey];
|
||||
await pool.query(`DELETE FROM ${cfg.table} WHERE id = ?`, [id]);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth, requireRole, CAN_MANAGE_CATALOGS } from '../../middleware/auth.js';
|
||||
import { getCatalog, postCatalog, putCatalog, deleteCatalog } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/:catalog', getCatalog);
|
||||
router.post('/:catalog', requireRole(...CAN_MANAGE_CATALOGS), postCatalog);
|
||||
router.put('/:catalog/:id', requireRole(...CAN_MANAGE_CATALOGS), putCatalog);
|
||||
router.delete('/:catalog/:id', requireRole(...CAN_MANAGE_CATALOGS), deleteCatalog);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ok, fail, asyncHandler } from '../../utils/http.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
import { ROLES } from '../../middleware/auth.js';
|
||||
import * as repo from './repository.js';
|
||||
|
||||
const VALID_ENTITY_TYPES = new Set(['project', 'activity']);
|
||||
|
||||
export const getComments = asyncHandler(async (req, res) => {
|
||||
const { entityType, entityId } = req.query;
|
||||
if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) return fail(res, 400, 'entityType y entityId son obligatorios');
|
||||
ok(res, await repo.findByEntity(entityType, Number(entityId)));
|
||||
});
|
||||
|
||||
export const postComment = asyncHandler(async (req, res) => {
|
||||
const { entityType, entityId, message } = req.body || {};
|
||||
if (!VALID_ENTITY_TYPES.has(entityType) || !entityId || !message) {
|
||||
throw new HttpError(400, 'entityType, entityId y message son obligatorios');
|
||||
}
|
||||
if (req.currentUser.roleCode === ROLES.VIEWER) throw new HttpError(403, 'Tu rol es de solo lectura');
|
||||
const comment = await repo.create({ entityType, entityId, userId: req.currentUser.id, message });
|
||||
await logAudit({ userId: req.currentUser.id, action: 'comment', entityType, entityId, detail: { commentId: comment.id } });
|
||||
ok(res, comment, 201);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function findByEntity(entityType, entityId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.created_at
|
||||
FROM comments c JOIN users u ON u.id = c.user_id
|
||||
WHERE c.entity_type = ? AND c.entity_id = ? ORDER BY c.created_at`,
|
||||
[entityType, entityId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function create({ entityType, entityId, userId, message }) {
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO comments (entity_type, entity_id, user_id, message) VALUES (?, ?, ?, ?)',
|
||||
[entityType, entityId, userId, message]
|
||||
);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.created_at
|
||||
FROM comments c JOIN users u ON u.id = c.user_id WHERE c.id = ?`,
|
||||
[result.insertId]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { getComments, postComment } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/', getComments);
|
||||
router.post('/', postComment);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ok, fail, asyncHandler } from '../../utils/http.js';
|
||||
import * as repo from './repository.js';
|
||||
import { listByProject } from '../activities/service.js';
|
||||
import { dueStatus } from '../../utils/dates.js';
|
||||
|
||||
export const getSummary = asyncHandler(async (req, res) => {
|
||||
const [kpis, myActivities, byStatus] = await Promise.all([
|
||||
repo.summary(),
|
||||
repo.myPendingActivities(req.currentUser.id),
|
||||
repo.projectsByStatus(),
|
||||
]);
|
||||
ok(res, {
|
||||
kpis,
|
||||
myActivities: myActivities.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date) })),
|
||||
projectsByStatus: byStatus,
|
||||
});
|
||||
});
|
||||
|
||||
export const getCalendar = asyncHandler(async (req, res) => {
|
||||
const month = req.query.month; // formato YYYY-MM
|
||||
if (!month || !/^\d{4}-\d{2}$/.test(month)) return fail(res, 400, 'Parametro month invalido (YYYY-MM)');
|
||||
const start = `${month}-01`;
|
||||
const [year, m] = month.split('-').map(Number);
|
||||
const end = new Date(year, m, 0).toISOString().slice(0, 10);
|
||||
const items = await repo.calendarActivities(start, end);
|
||||
ok(res, items.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date, a.status_is_closed) })));
|
||||
});
|
||||
|
||||
export const getGantt = asyncHandler(async (req, res) => {
|
||||
const activities = await listByProject(Number(req.params.id));
|
||||
ok(res, activities.map((a) => ({
|
||||
id: a.id, code: a.code, name: a.name, startDate: a.start_date, dueDate: a.due_date,
|
||||
progressPct: a.progress_pct, isMilestone: !!a.is_milestone, responsibleName: a.responsible_name,
|
||||
})));
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function summary() {
|
||||
const [[activeProjects]] = await pool.query(
|
||||
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id WHERE ps.is_closed = 0`
|
||||
);
|
||||
const [[overdueProjects]] = await pool.query(
|
||||
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id
|
||||
WHERE ps.is_closed = 0 AND p.estimated_end_date IS NOT NULL AND p.estimated_end_date < CURDATE()`
|
||||
);
|
||||
const [[dueSoonProjects]] = await pool.query(
|
||||
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id
|
||||
WHERE ps.is_closed = 0 AND p.estimated_end_date IS NOT NULL
|
||||
AND p.estimated_end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY)`
|
||||
);
|
||||
const [[pendingActivities]] = await pool.query(
|
||||
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id WHERE st.is_closed = 0`
|
||||
);
|
||||
const [[overdueActivities]] = await pool.query(
|
||||
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id
|
||||
WHERE st.is_closed = 0 AND a.due_date IS NOT NULL AND a.due_date < CURDATE()`
|
||||
);
|
||||
const [[todayActivities]] = await pool.query(
|
||||
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id
|
||||
WHERE st.is_closed = 0 AND a.due_date = CURDATE()`
|
||||
);
|
||||
const [[avgProgress]] = await pool.query(
|
||||
`SELECT AVG(progress_pct) AS avg_pct FROM projects p JOIN project_statuses ps ON ps.id = p.status_id WHERE ps.is_closed = 0`
|
||||
);
|
||||
|
||||
return {
|
||||
activeProjects: activeProjects.c,
|
||||
overdueProjects: overdueProjects.c,
|
||||
dueSoonProjects: dueSoonProjects.c,
|
||||
pendingActivities: pendingActivities.c,
|
||||
overdueActivities: overdueActivities.c,
|
||||
todayActivities: todayActivities.c,
|
||||
averageProgress: avgProgress.avg_pct === null ? 0 : Math.round(Number(avgProgress.avg_pct) * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
export async function myPendingActivities(userId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT a.id, a.code, a.name, a.due_date, a.progress_pct, p.id AS project_id, p.name AS project_name
|
||||
FROM activities a JOIN activity_statuses st ON st.id = a.status_id JOIN projects p ON p.id = a.project_id
|
||||
WHERE st.is_closed = 0 AND a.responsible_user_id = ?
|
||||
ORDER BY (a.due_date IS NULL), a.due_date LIMIT 20`,
|
||||
[userId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function calendarActivities(monthStart, monthEnd) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT a.id, a.code, a.name, a.due_date, a.is_milestone, a.progress_pct,
|
||||
st.is_closed AS status_is_closed, p.id AS project_id, p.name AS project_name
|
||||
FROM activities a JOIN activity_statuses st ON st.id = a.status_id JOIN projects p ON p.id = a.project_id
|
||||
WHERE a.due_date BETWEEN ? AND ?
|
||||
ORDER BY a.due_date`,
|
||||
[monthStart, monthEnd]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function projectsByStatus() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT ps.name, ps.color, COUNT(p.id) AS total
|
||||
FROM project_statuses ps LEFT JOIN projects p ON p.status_id = ps.id
|
||||
GROUP BY ps.id ORDER BY ps.sort_order`
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { getSummary, getCalendar } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/summary', getSummary);
|
||||
router.get('/calendar', getCalendar);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as service from './service.js';
|
||||
|
||||
export const getDepartments = asyncHandler(async (req, res) => ok(res, await service.listTree()));
|
||||
|
||||
export const postDepartment = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.createDepartment(req.body, req.currentUser.id), 201);
|
||||
});
|
||||
|
||||
export const putDepartment = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.updateDepartment(Number(req.params.id), req.body, req.currentUser.id));
|
||||
});
|
||||
|
||||
export const deleteDepartment = asyncHandler(async (req, res) => {
|
||||
await service.deleteDepartment(Number(req.params.id), req.currentUser.id);
|
||||
ok(res, { deleted: true });
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function findAllFlat() {
|
||||
const [rows] = await pool.query('SELECT id, name, parent_id FROM departments ORDER BY name');
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function create({ name, parentId }) {
|
||||
const [result] = await pool.query('INSERT INTO departments (name, parent_id) VALUES (?, ?)', [
|
||||
name,
|
||||
parentId || null,
|
||||
]);
|
||||
return { id: result.insertId, name, parent_id: parentId || null };
|
||||
}
|
||||
|
||||
export async function update(id, { name, parentId }) {
|
||||
await pool.query('UPDATE departments SET name = ?, parent_id = ? WHERE id = ?', [name, parentId || null, id]);
|
||||
return { id, name, parent_id: parentId || null };
|
||||
}
|
||||
|
||||
export async function remove(id) {
|
||||
await pool.query('DELETE FROM departments WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
export async function hasChildren(id) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS c FROM departments WHERE parent_id = ?', [id]);
|
||||
return rows[0].c > 0;
|
||||
}
|
||||
|
||||
export async function hasUsers(id) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS c FROM users WHERE department_id = ?', [id]);
|
||||
return rows[0].c > 0;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth, requireRole, CAN_MANAGE_USERS } from '../../middleware/auth.js';
|
||||
import { getDepartments, postDepartment, putDepartment, deleteDepartment } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/', getDepartments);
|
||||
router.post('/', requireRole(...CAN_MANAGE_USERS), postDepartment);
|
||||
router.put('/:id', requireRole(...CAN_MANAGE_USERS), putDepartment);
|
||||
router.delete('/:id', requireRole(...CAN_MANAGE_USERS), deleteDepartment);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as repo from './repository.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
|
||||
function toTree(flat) {
|
||||
const byId = new Map(flat.map((d) => [d.id, { ...d, children: [] }]));
|
||||
const roots = [];
|
||||
for (const dept of byId.values()) {
|
||||
if (dept.parent_id && byId.has(dept.parent_id)) {
|
||||
byId.get(dept.parent_id).children.push(dept);
|
||||
} else {
|
||||
roots.push(dept);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
export async function listTree() {
|
||||
const flat = await repo.findAllFlat();
|
||||
return toTree(flat);
|
||||
}
|
||||
|
||||
export async function createDepartment(payload, actorId) {
|
||||
if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio');
|
||||
const dept = await repo.create(payload);
|
||||
await logAudit({ userId: actorId, action: 'create', entityType: 'department', entityId: dept.id });
|
||||
return dept;
|
||||
}
|
||||
|
||||
export async function updateDepartment(id, payload, actorId) {
|
||||
if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio');
|
||||
if (Number(payload.parentId) === Number(id)) throw new HttpError(400, 'Un departamento no puede ser su propio padre');
|
||||
const dept = await repo.update(id, payload);
|
||||
await logAudit({ userId: actorId, action: 'update', entityType: 'department', entityId: id });
|
||||
return dept;
|
||||
}
|
||||
|
||||
export async function deleteDepartment(id, actorId) {
|
||||
if (await repo.hasChildren(id)) throw new HttpError(400, 'Tiene sub-departamentos; reasignalos antes de eliminar');
|
||||
if (await repo.hasUsers(id)) throw new HttpError(400, 'Tiene usuarios asignados; reasignalos antes de eliminar');
|
||||
await repo.remove(id);
|
||||
await logAudit({ userId: actorId, action: 'delete', entityType: 'department', entityId: id });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as service from './service.js';
|
||||
|
||||
export const getNotifications = asyncHandler(async (req, res) => {
|
||||
const [items, unread] = await Promise.all([
|
||||
service.listMine(req.currentUser.id),
|
||||
service.countUnread(req.currentUser.id),
|
||||
]);
|
||||
ok(res, { items, unread });
|
||||
});
|
||||
|
||||
export const patchNotificationRead = asyncHandler(async (req, res) => {
|
||||
await service.markRead(Number(req.params.id), req.currentUser.id);
|
||||
ok(res, { read: true });
|
||||
});
|
||||
|
||||
export const patchAllRead = asyncHandler(async (req, res) => {
|
||||
await service.markAllRead(req.currentUser.id);
|
||||
ok(res, { read: true });
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function findByUser(userId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, type, message, entity_type, entity_id, is_read, created_at
|
||||
FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`,
|
||||
[userId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function countUnread(userId) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS c FROM notifications WHERE user_id = ? AND is_read = 0', [userId]);
|
||||
return rows[0].c;
|
||||
}
|
||||
|
||||
export async function create({ userId, type, message, entityType, entityId }) {
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO notifications (user_id, type, message, entity_type, entity_id) VALUES (?, ?, ?, ?, ?)',
|
||||
[userId, type, message, entityType || null, entityId || null]
|
||||
);
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
export async function markRead(id, userId) {
|
||||
await pool.query('UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?', [id, userId]);
|
||||
}
|
||||
|
||||
export async function markAllRead(userId) {
|
||||
await pool.query('UPDATE notifications SET is_read = 1 WHERE user_id = ?', [userId]);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { getNotifications, patchNotificationRead, patchAllRead } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/', getNotifications);
|
||||
router.patch('/read-all', patchAllRead);
|
||||
router.patch('/:id/read', patchNotificationRead);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as repo from './repository.js';
|
||||
|
||||
export const listMine = (userId) => repo.findByUser(userId);
|
||||
export const countUnread = (userId) => repo.countUnread(userId);
|
||||
export const createNotification = (payload) => repo.create(payload);
|
||||
export const markRead = (id, userId) => repo.markRead(id, userId);
|
||||
export const markAllRead = (userId) => repo.markAllRead(userId);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as service from './service.js';
|
||||
|
||||
export const getProjects = asyncHandler(async (req, res) => {
|
||||
const { statusId, priorityId, ownerUserId } = req.query;
|
||||
ok(res, await service.listProjects({ statusId, priorityId, ownerUserId }));
|
||||
});
|
||||
|
||||
export const getProject = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.getProject(Number(req.params.id)));
|
||||
});
|
||||
|
||||
export const postProject = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.createProject(req.body, req.currentUser), 201);
|
||||
});
|
||||
|
||||
export const putProject = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.updateProject(Number(req.params.id), req.body, req.currentUser));
|
||||
});
|
||||
|
||||
export const postMember = asyncHandler(async (req, res) => {
|
||||
const members = await service.addMember(Number(req.params.id), Number(req.body.userId), req.body.roleInProject, req.currentUser);
|
||||
ok(res, members, 201);
|
||||
});
|
||||
|
||||
export const deleteMember = asyncHandler(async (req, res) => {
|
||||
const members = await service.removeMember(Number(req.params.id), Number(req.params.userId), req.currentUser);
|
||||
ok(res, members);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
const LIST_SELECT = `
|
||||
SELECT p.id, p.code, p.name, p.description, p.objective, p.project_type_id, pt.name AS project_type_name,
|
||||
p.priority_id, pr.name AS priority_name, pr.color AS priority_color,
|
||||
p.status_id, ps.name AS status_name, ps.color AS status_color, ps.is_closed AS status_is_closed,
|
||||
p.owner_user_id, u.name AS owner_name,
|
||||
p.requesting_department_id, rd.name AS requesting_department_name,
|
||||
p.responsible_department_id, sd.name AS responsible_department_name,
|
||||
p.start_date, p.estimated_end_date, p.actual_end_date, p.progress_pct,
|
||||
p.risk_level, p.notes, p.created_by, p.created_at, p.updated_at
|
||||
FROM projects p
|
||||
JOIN priorities pr ON pr.id = p.priority_id
|
||||
JOIN project_statuses ps ON ps.id = p.status_id
|
||||
JOIN users u ON u.id = p.owner_user_id
|
||||
LEFT JOIN project_types pt ON pt.id = p.project_type_id
|
||||
LEFT JOIN departments rd ON rd.id = p.requesting_department_id
|
||||
LEFT JOIN departments sd ON sd.id = p.responsible_department_id
|
||||
`;
|
||||
|
||||
export async function findAll({ statusId, priorityId, ownerUserId } = {}) {
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
if (statusId) { clauses.push('p.status_id = ?'); params.push(statusId); }
|
||||
if (priorityId) { clauses.push('p.priority_id = ?'); params.push(priorityId); }
|
||||
if (ownerUserId) { clauses.push('p.owner_user_id = ?'); params.push(ownerUserId); }
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.created_at DESC`, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function findById(id) {
|
||||
const [rows] = await pool.query(`${LIST_SELECT} WHERE p.id = ?`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function create(data) {
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO projects
|
||||
(code, name, description, objective, project_type_id, priority_id, status_id, owner_user_id,
|
||||
requesting_department_id, responsible_department_id, start_date, estimated_end_date,
|
||||
risk_level, notes, created_by)
|
||||
VALUES ('PENDING', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
data.name, data.description || null, data.objective || null, data.projectTypeId || null,
|
||||
data.priorityId, data.statusId, data.ownerUserId,
|
||||
data.requestingDepartmentId || null, data.responsibleDepartmentId || null,
|
||||
data.startDate || null, data.estimatedEndDate || null,
|
||||
data.riskLevel || 'low', data.notes || null, data.createdBy,
|
||||
]
|
||||
);
|
||||
const id = result.insertId;
|
||||
const code = `PRY-${String(id).padStart(4, '0')}`;
|
||||
await pool.query('UPDATE projects SET code = ? WHERE id = ?', [code, id]);
|
||||
// El creador queda como miembro por defecto.
|
||||
await pool.query('INSERT IGNORE INTO project_members (project_id, user_id, role_in_project) VALUES (?, ?, ?)', [
|
||||
id, data.ownerUserId, 'owner',
|
||||
]);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function update(id, data) {
|
||||
await pool.query(
|
||||
`UPDATE projects SET
|
||||
name = ?, description = ?, objective = ?, project_type_id = ?, priority_id = ?, status_id = ?,
|
||||
owner_user_id = ?, requesting_department_id = ?, responsible_department_id = ?,
|
||||
start_date = ?, estimated_end_date = ?, actual_end_date = ?, risk_level = ?, notes = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
data.name, data.description || null, data.objective || null, data.projectTypeId || null,
|
||||
data.priorityId, data.statusId, data.ownerUserId,
|
||||
data.requestingDepartmentId || null, data.responsibleDepartmentId || null,
|
||||
data.startDate || null, data.estimatedEndDate || null, data.actualEndDate || null,
|
||||
data.riskLevel || 'low', data.notes || null, id,
|
||||
]
|
||||
);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function updateProgress(id, progressPct) {
|
||||
await pool.query('UPDATE projects SET progress_pct = ? WHERE id = ?', [progressPct, id]);
|
||||
}
|
||||
|
||||
export async function listMembers(projectId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT pm.user_id, u.name, u.email, pm.role_in_project, pm.added_at
|
||||
FROM project_members pm JOIN users u ON u.id = pm.user_id
|
||||
WHERE pm.project_id = ? ORDER BY u.name`,
|
||||
[projectId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function isMember(projectId, userId) {
|
||||
const [rows] = await pool.query('SELECT 1 FROM project_members WHERE project_id = ? AND user_id = ?', [
|
||||
projectId, userId,
|
||||
]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export async function addMember(projectId, userId, roleInProject) {
|
||||
await pool.query(
|
||||
'INSERT IGNORE INTO project_members (project_id, user_id, role_in_project) VALUES (?, ?, ?)',
|
||||
[projectId, userId, roleInProject || null]
|
||||
);
|
||||
return listMembers(projectId);
|
||||
}
|
||||
|
||||
export async function removeMember(projectId, userId) {
|
||||
await pool.query('DELETE FROM project_members WHERE project_id = ? AND user_id = ?', [projectId, userId]);
|
||||
return listMembers(projectId);
|
||||
}
|
||||
|
||||
export async function averageActivityProgress(projectId) {
|
||||
const [rows] = await pool.query('SELECT AVG(progress_pct) AS avg_pct FROM activities WHERE project_id = ?', [
|
||||
projectId,
|
||||
]);
|
||||
return rows[0].avg_pct === null ? 0 : Number(rows[0].avg_pct);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { getProjects, getProject, postProject, putProject, postMember, deleteMember } from './controller.js';
|
||||
import { getGantt } from '../dashboard/controller.js';
|
||||
import { getActivitiesByProject, postActivity } from '../activities/controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/', getProjects);
|
||||
router.post('/', postProject);
|
||||
router.get('/:id', getProject);
|
||||
router.put('/:id', putProject);
|
||||
router.get('/:id/gantt', getGantt);
|
||||
router.post('/:id/members', postMember);
|
||||
router.delete('/:id/members/:userId', deleteMember);
|
||||
router.get('/:projectId/activities', getActivitiesByProject);
|
||||
router.post('/:projectId/activities', postActivity);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as repo from './repository.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
import { ROLES } from '../../middleware/auth.js';
|
||||
|
||||
// Alcance de escritura: admin/it_manager siempre; project_lead solo si es dueño o miembro.
|
||||
export async function canWriteProject(user, project) {
|
||||
if (user.roleCode === ROLES.ADMIN || user.roleCode === ROLES.IT_MANAGER) return true;
|
||||
if (user.roleCode !== ROLES.PROJECT_LEAD) return false;
|
||||
if (project.owner_user_id === user.id) return true;
|
||||
return repo.isMember(project.id, user.id);
|
||||
}
|
||||
|
||||
export const listProjects = (filters) => repo.findAll(filters);
|
||||
|
||||
export async function getProject(id) {
|
||||
const project = await repo.findById(id);
|
||||
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
|
||||
const members = await repo.listMembers(id);
|
||||
return { ...project, members };
|
||||
}
|
||||
|
||||
export async function createProject(payload, currentUser) {
|
||||
if (!payload.name || !payload.priorityId || !payload.statusId) {
|
||||
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
|
||||
}
|
||||
if (![ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD].includes(currentUser.roleCode)) {
|
||||
throw new HttpError(403, 'No tienes permisos para crear proyectos');
|
||||
}
|
||||
const project = await repo.create({
|
||||
...payload,
|
||||
ownerUserId: payload.ownerUserId || currentUser.id,
|
||||
createdBy: currentUser.id,
|
||||
});
|
||||
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'project', entityId: project.id });
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function updateProject(id, payload, currentUser) {
|
||||
const project = await repo.findById(id);
|
||||
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
|
||||
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
|
||||
if (!payload.name || !payload.priorityId || !payload.statusId) {
|
||||
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
|
||||
}
|
||||
const updated = await repo.update(id, { ...payload, ownerUserId: payload.ownerUserId || project.owner_user_id });
|
||||
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'project', entityId: id });
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Recalcula el avance del proyecto como promedio del avance de sus actividades.
|
||||
export async function recalcProjectProgress(projectId) {
|
||||
const avg = await repo.averageActivityProgress(projectId);
|
||||
await repo.updateProgress(projectId, Math.round(avg * 100) / 100);
|
||||
}
|
||||
|
||||
export async function addMember(projectId, userId, roleInProject, currentUser) {
|
||||
const project = await repo.findById(projectId);
|
||||
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
|
||||
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
|
||||
const members = await repo.addMember(projectId, userId, roleInProject);
|
||||
await logAudit({ userId: currentUser.id, action: 'add_member', entityType: 'project', entityId: projectId, detail: { userId } });
|
||||
return members;
|
||||
}
|
||||
|
||||
export async function removeMember(projectId, userId, currentUser) {
|
||||
const project = await repo.findById(projectId);
|
||||
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
|
||||
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
|
||||
const members = await repo.removeMember(projectId, userId);
|
||||
await logAudit({ userId: currentUser.id, action: 'remove_member', entityType: 'project', entityId: projectId, detail: { userId } });
|
||||
return members;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as service from './service.js';
|
||||
|
||||
export const getSubtasks = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.listByActivity(Number(req.params.activityId)));
|
||||
});
|
||||
|
||||
export const postSubtask = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.createSubtask(Number(req.params.activityId), req.body, req.currentUser), 201);
|
||||
});
|
||||
|
||||
export const putSubtask = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.updateSubtask(Number(req.params.id), req.body, req.currentUser));
|
||||
});
|
||||
|
||||
export const patchSubtaskDone = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.setSubtaskDone(Number(req.params.id), !!req.body.isDone, req.currentUser));
|
||||
});
|
||||
|
||||
export const deleteSubtask = asyncHandler(async (req, res) => {
|
||||
await service.removeSubtask(Number(req.params.id), req.currentUser);
|
||||
ok(res, { deleted: true });
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function findByActivity(activityId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT s.id, s.activity_id, s.name, s.responsible_user_id, u.name AS responsible_name,
|
||||
s.is_done, s.due_date, s.created_at
|
||||
FROM subtasks s LEFT JOIN users u ON u.id = s.responsible_user_id
|
||||
WHERE s.activity_id = ? ORDER BY s.created_at`,
|
||||
[activityId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function findById(id) {
|
||||
const [rows] = await pool.query('SELECT * FROM subtasks WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function create(activityId, { name, responsibleUserId, dueDate }) {
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO subtasks (activity_id, name, responsible_user_id, due_date) VALUES (?, ?, ?, ?)',
|
||||
[activityId, name, responsibleUserId || null, dueDate || null]
|
||||
);
|
||||
return findById(result.insertId);
|
||||
}
|
||||
|
||||
export async function update(id, { name, responsibleUserId, dueDate }) {
|
||||
await pool.query('UPDATE subtasks SET name = ?, responsible_user_id = ?, due_date = ? WHERE id = ?', [
|
||||
name, responsibleUserId || null, dueDate || null, id,
|
||||
]);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function setDone(id, isDone) {
|
||||
await pool.query('UPDATE subtasks SET is_done = ? WHERE id = ?', [isDone ? 1 : 0, id]);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function remove(id) {
|
||||
await pool.query('DELETE FROM subtasks WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
export async function countsByActivity(activityId) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT COUNT(*) AS total, SUM(is_done) AS done FROM subtasks WHERE activity_id = ?',
|
||||
[activityId]
|
||||
);
|
||||
return { total: rows[0].total, done: Number(rows[0].done) || 0 };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { getSubtasks, postSubtask, putSubtask, patchSubtaskDone, deleteSubtask } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/activities/:activityId/subtasks', getSubtasks);
|
||||
router.post('/activities/:activityId/subtasks', postSubtask);
|
||||
router.put('/subtasks/:id', putSubtask);
|
||||
router.patch('/subtasks/:id/done', patchSubtaskDone);
|
||||
router.delete('/subtasks/:id', deleteSubtask);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as repo from './repository.js';
|
||||
import * as activitiesRepo from '../activities/repository.js';
|
||||
import * as projectsRepo from '../projects/repository.js';
|
||||
import { canWriteActivity, recalcFromSubtasks } from '../activities/service.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
|
||||
async function requireActivityAccess(activityId, currentUser) {
|
||||
const activity = await activitiesRepo.findById(activityId);
|
||||
if (!activity) throw new HttpError(404, 'Actividad no encontrada');
|
||||
const project = await projectsRepo.findById(activity.project_id);
|
||||
if (!(await canWriteActivity(currentUser, project, activity))) {
|
||||
throw new HttpError(403, 'No tienes permisos sobre esta actividad');
|
||||
}
|
||||
return activity;
|
||||
}
|
||||
|
||||
export const listByActivity = (activityId) => repo.findByActivity(activityId);
|
||||
|
||||
export async function createSubtask(activityId, payload, currentUser) {
|
||||
await requireActivityAccess(activityId, currentUser);
|
||||
if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio');
|
||||
const subtask = await repo.create(activityId, payload);
|
||||
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'subtask', entityId: subtask.id });
|
||||
const counts = await repo.countsByActivity(activityId);
|
||||
await recalcFromSubtasks(activityId, counts.done, counts.total);
|
||||
return subtask;
|
||||
}
|
||||
|
||||
export async function updateSubtask(id, payload, currentUser) {
|
||||
const subtask = await repo.findById(id);
|
||||
if (!subtask) throw new HttpError(404, 'Subtarea no encontrada');
|
||||
await requireActivityAccess(subtask.activity_id, currentUser);
|
||||
if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio');
|
||||
const updated = await repo.update(id, payload);
|
||||
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'subtask', entityId: id });
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function setSubtaskDone(id, isDone, currentUser) {
|
||||
const subtask = await repo.findById(id);
|
||||
if (!subtask) throw new HttpError(404, 'Subtarea no encontrada');
|
||||
await requireActivityAccess(subtask.activity_id, currentUser);
|
||||
const updated = await repo.setDone(id, isDone);
|
||||
await logAudit({ userId: currentUser.id, action: isDone ? 'complete' : 'reopen', entityType: 'subtask', entityId: id });
|
||||
const counts = await repo.countsByActivity(subtask.activity_id);
|
||||
await recalcFromSubtasks(subtask.activity_id, counts.done, counts.total);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function removeSubtask(id, currentUser) {
|
||||
const subtask = await repo.findById(id);
|
||||
if (!subtask) throw new HttpError(404, 'Subtarea no encontrada');
|
||||
await requireActivityAccess(subtask.activity_id, currentUser);
|
||||
await repo.remove(id);
|
||||
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'subtask', entityId: id });
|
||||
const counts = await repo.countsByActivity(subtask.activity_id);
|
||||
await recalcFromSubtasks(subtask.activity_id, counts.done, counts.total);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as service from './service.js';
|
||||
|
||||
export const getUsers = asyncHandler(async (req, res) => ok(res, await service.listUsers()));
|
||||
export const getRoles = asyncHandler(async (req, res) => ok(res, await service.listRoles()));
|
||||
|
||||
export const postUser = asyncHandler(async (req, res) => {
|
||||
const user = await service.createUser(req.body, req.currentUser.id);
|
||||
ok(res, user, 201);
|
||||
});
|
||||
|
||||
export const putUser = asyncHandler(async (req, res) => {
|
||||
const user = await service.updateUser(Number(req.params.id), req.body, req.currentUser.id);
|
||||
ok(res, user);
|
||||
});
|
||||
|
||||
export const patchUserActive = asyncHandler(async (req, res) => {
|
||||
const user = await service.setUserActive(Number(req.params.id), !!req.body.isActive, req.currentUser.id);
|
||||
ok(res, user);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function findAll() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, r.name AS role_name,
|
||||
u.department_id, d.name AS department_name, u.position, u.is_active, u.created_at
|
||||
FROM users u
|
||||
JOIN roles r ON r.id = u.role_id
|
||||
LEFT JOIN departments d ON d.id = u.department_id
|
||||
ORDER BY u.name`
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function findById(id) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, u.department_id, u.position, u.is_active
|
||||
FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`,
|
||||
[id]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function findByEmail(email) {
|
||||
const [rows] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function create({ name, email, passwordHash, roleId, departmentId, position }) {
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO users (name, email, password_hash, role_id, department_id, position, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1)`,
|
||||
[name, email, passwordHash, roleId, departmentId || null, position || null]
|
||||
);
|
||||
return findById(result.insertId);
|
||||
}
|
||||
|
||||
export async function update(id, { name, roleId, departmentId, position }) {
|
||||
await pool.query(
|
||||
`UPDATE users SET name = ?, role_id = ?, department_id = ?, position = ? WHERE id = ?`,
|
||||
[name, roleId, departmentId || null, position || null, id]
|
||||
);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function setActive(id, isActive) {
|
||||
await pool.query('UPDATE users SET is_active = ? WHERE id = ?', [isActive ? 1 : 0, id]);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function listRoles() {
|
||||
const [rows] = await pool.query('SELECT id, code, name FROM roles ORDER BY id');
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth, requireRole, CAN_MANAGE_USERS } from '../../middleware/auth.js';
|
||||
import { getUsers, getRoles, postUser, putUser, patchUserActive } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/', getUsers);
|
||||
router.get('/roles', getRoles);
|
||||
router.post('/', requireRole(...CAN_MANAGE_USERS), postUser);
|
||||
router.put('/:id', requireRole(...CAN_MANAGE_USERS), putUser);
|
||||
router.patch('/:id/active', requireRole(...CAN_MANAGE_USERS), patchUserActive);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,41 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import * as repo from './repository.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
|
||||
export const listUsers = () => repo.findAll();
|
||||
export const listRoles = () => repo.listRoles();
|
||||
|
||||
export async function createUser(payload, actorId) {
|
||||
if (!payload.name || !payload.email || !payload.password || !payload.roleId) {
|
||||
throw new HttpError(400, 'Nombre, email, clave y rol son obligatorios');
|
||||
}
|
||||
const existing = await repo.findByEmail(payload.email);
|
||||
if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email');
|
||||
|
||||
const passwordHash = await bcrypt.hash(payload.password, 12);
|
||||
const user = await repo.create({ ...payload, passwordHash });
|
||||
await logAudit({ userId: actorId, action: 'create', entityType: 'user', entityId: user.id });
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function updateUser(id, payload, actorId) {
|
||||
const existing = await repo.findById(id);
|
||||
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
|
||||
const user = await repo.update(id, payload);
|
||||
await logAudit({ userId: actorId, action: 'update', entityType: 'user', entityId: id });
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function setUserActive(id, isActive, actorId) {
|
||||
const existing = await repo.findById(id);
|
||||
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
|
||||
const user = await repo.setActive(id, isActive);
|
||||
await logAudit({
|
||||
userId: actorId,
|
||||
action: isActive ? 'activate' : 'deactivate',
|
||||
entityType: 'user',
|
||||
entityId: id,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
Reference in New Issue
Block a user