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:
Milton Gonzalez
2026-09-03 08:37:58 -05:00
commit 1d22adc42e
135 changed files with 10913 additions and 0 deletions
@@ -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;
}
+13
View File
@@ -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;
+130
View File
@@ -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]);
}
+14
View File
@@ -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;
+7
View File
@@ -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 }));
});
+16
View File
@@ -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;
}
+10
View File
@@ -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;
+18
View File
@@ -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);
});
+19
View File
@@ -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;
+36
View File
@@ -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]);
}
+13
View File
@@ -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];
}
+11
View File
@@ -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;
}
+11
View File
@@ -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;
}
+13
View File
@@ -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);
});
+119
View File
@@ -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);
}
+20
View File
@@ -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;
+73
View File
@@ -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 };
}
+14
View File
@@ -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;
+59
View File
@@ -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);
}
+20
View File
@@ -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);
});
+54
View File
@@ -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;
}
+14
View File
@@ -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;
+41
View File
@@ -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;
}