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
+42
View File
@@ -0,0 +1,42 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { env } from './config/env.js';
import { notFoundHandler, errorHandler } from './middleware/errorHandler.js';
import authRoutes from './modules/auth/routes.js';
import usersRoutes from './modules/users/routes.js';
import departmentsRoutes from './modules/departments/routes.js';
import catalogsRoutes from './modules/catalogs/routes.js';
import projectsRoutes from './modules/projects/routes.js';
import activitiesRoutes from './modules/activities/routes.js';
import subtasksRoutes from './modules/subtasks/routes.js';
import commentsRoutes from './modules/comments/routes.js';
import attachmentsRoutes from './modules/attachments/routes.js';
import notificationsRoutes from './modules/notifications/routes.js';
import dashboardRoutes from './modules/dashboard/routes.js';
import auditRoutes from './modules/audit/routes.js';
export const app = express();
app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors({ origin: env.corsOrigin, credentials: true }));
app.use(express.json({ limit: '2mb' }));
app.get('/health', (req, res) => res.json({ success: true, data: { status: 'ok' }, error: null }));
app.use('/api/auth', authRoutes);
app.use('/api/users', usersRoutes);
app.use('/api/departments', departmentsRoutes);
app.use('/api/catalogs', catalogsRoutes);
app.use('/api/projects', projectsRoutes);
app.use('/api/activities', activitiesRoutes);
app.use('/api', subtasksRoutes);
app.use('/api/comments', commentsRoutes);
app.use('/api/attachments', attachmentsRoutes);
app.use('/api/notifications', notificationsRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/audit', auditRoutes);
app.use(notFoundHandler);
app.use(errorHandler);
+13
View File
@@ -0,0 +1,13 @@
import mysql from 'mysql2/promise';
import { env } from './env.js';
export const pool = mysql.createPool({
host: env.db.host,
port: env.db.port,
database: env.db.name,
user: env.db.user,
password: env.db.password,
waitForConnections: true,
connectionLimit: 10,
dateStrings: true,
});
+32
View File
@@ -0,0 +1,32 @@
import 'dotenv/config';
const required = ['DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'JWT_SECRET'];
for (const key of required) {
if (!process.env[key]) {
if (process.env.NODE_ENV === 'production') {
throw new Error(`Falta la variable de entorno obligatoria: ${key}`);
}
console.warn(`[env] Falta ${key} — completa backend/.env`);
}
}
if (process.env.NODE_ENV === 'production' && (process.env.JWT_SECRET || '').length < 32) {
throw new Error('JWT_SECRET debe tener al menos 32 caracteres en producción');
}
export const env = {
port: Number(process.env.PORT || 4019),
nodeEnv: process.env.NODE_ENV || 'development',
db: {
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT || 3306),
name: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
},
jwtSecret: process.env.JWT_SECRET || 'dev-only-insecure-secret',
jwtExpires: process.env.JWT_EXPIRES || '8h',
corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:5196',
storageRoot: process.env.STORAGE_ROOT || './storage',
};
+58
View File
@@ -0,0 +1,58 @@
import jwt from 'jsonwebtoken';
import { env } from '../config/env.js';
import { pool } from '../config/db.js';
import { fail } from '../utils/http.js';
export async function requireAuth(req, res, next) {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return fail(res, 401, 'No autenticado');
let payload;
try {
payload = jwt.verify(token, env.jwtSecret);
} catch {
return fail(res, 401, 'Token invalido o expirado');
}
// Revalidar en cada request que la cuenta siga activa (un JWT firmado no basta).
const [rows] = await pool.query(
`SELECT u.id, u.name, u.email, u.is_active, u.department_id, r.code AS role_code
FROM users u JOIN roles r ON r.id = u.role_id
WHERE u.id = ? LIMIT 1`,
[payload.sub]
);
const user = rows[0];
if (!user || !user.is_active) return fail(res, 401, 'Cuenta inactiva o inexistente');
req.currentUser = {
id: user.id,
name: user.name,
email: user.email,
roleCode: user.role_code,
departmentId: user.department_id,
};
next();
}
export function requireRole(...roleCodes) {
return (req, res, next) => {
if (!req.currentUser) return fail(res, 401, 'No autenticado');
if (!roleCodes.includes(req.currentUser.roleCode)) {
return fail(res, 403, 'No tienes permisos para esta accion');
}
next();
};
}
export const ROLES = {
ADMIN: 'admin',
IT_MANAGER: 'it_manager',
PROJECT_LEAD: 'project_lead',
COLLABORATOR: 'collaborator',
VIEWER: 'viewer',
};
export const CAN_MANAGE_CATALOGS = [ROLES.ADMIN];
export const CAN_MANAGE_USERS = [ROLES.ADMIN];
export const CAN_WRITE = [ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD, ROLES.COLLABORATOR];
+18
View File
@@ -0,0 +1,18 @@
import { HttpError } from '../utils/http.js';
export function notFoundHandler(req, res) {
res.status(404).json({ success: false, data: null, error: 'Ruta no encontrada' });
}
export function errorHandler(err, req, res, next) { // eslint-disable-line no-unused-vars
if (err instanceof HttpError) {
return res.status(err.status).json({ success: false, data: null, error: err.message });
}
console.error('[error]', err);
const isProd = process.env.NODE_ENV === 'production';
res.status(500).json({
success: false,
data: null,
error: isProd ? 'Error interno' : err.message,
});
}
+43
View File
@@ -0,0 +1,43 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import multer from 'multer';
import { env } from '../config/env.js';
const ALLOWED_MIME = new Set([
'application/pdf',
'image/png',
'image/jpeg',
'image/webp',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
'application/zip',
]);
const uploadsDir = path.join(env.storageRoot, 'attachments');
fs.mkdirSync(uploadsDir, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadsDir),
// Nombre generado por el servidor: evita path traversal y colisiones.
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).slice(0, 10);
cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`);
},
});
export const upload = multer({
storage,
limits: { fileSize: 15 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (!ALLOWED_MIME.has(file.mimetype)) {
return cb(new Error('Tipo de archivo no permitido'));
}
cb(null, true);
},
});
export { uploadsDir };
@@ -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;
}
+34
View File
@@ -0,0 +1,34 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import mysql from 'mysql2/promise';
import { env } from '../config/env.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const migrationsDir = path.resolve(__dirname, '../../database/migrations');
async function run() {
const connection = await mysql.createConnection({
host: env.db.host,
port: env.db.port,
database: env.db.name,
user: env.db.user,
password: env.db.password,
multipleStatements: true,
});
const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith('.sql')).sort();
for (const file of files) {
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
console.log(`[migrate] aplicando ${file}`);
await connection.query(sql);
}
console.log(`[migrate] listo — ${files.length} archivo(s) aplicados`);
await connection.end();
}
run().catch((err) => {
console.error('[migrate] error:', err.message);
process.exit(1);
});
+108
View File
@@ -0,0 +1,108 @@
import bcrypt from 'bcryptjs';
import mysql from 'mysql2/promise';
import { env } from '../config/env.js';
async function run() {
const db = await mysql.createConnection({
host: env.db.host,
port: env.db.port,
database: env.db.name,
user: env.db.user,
password: env.db.password,
});
// Roles fijos del MVP.
const roles = [
['admin', 'Administrador'],
['it_manager', 'Jefe de TI'],
['project_lead', 'Coordinador / Lider de proyecto'],
['collaborator', 'Colaborador'],
['viewer', 'Consultor (solo lectura)'],
];
for (const [code, name] of roles) {
await db.query('INSERT IGNORE INTO roles (code, name) VALUES (?, ?)', [code, name]);
}
const priorities = [
['Baja', 1, 'muted'],
['Media', 2, 'accent'],
['Alta', 3, 'warning'],
['Critica', 4, 'danger'],
];
for (const [name, level, color] of priorities) {
await db.query('INSERT IGNORE INTO priorities (name, level, color) VALUES (?, ?, ?)', [name, level, color]);
}
const projectStatuses = [
['Planificado', 1, 'muted', 0],
['En preparacion', 2, 'accent', 0],
['En ejecucion', 3, 'accent', 0],
['En pausa', 4, 'warning', 0],
['Bloqueado', 5, 'danger', 0],
['Completado', 6, 'success', 1],
['Cancelado', 7, 'muted', 1],
];
for (const [name, sort, color, closed] of projectStatuses) {
await db.query(
'INSERT IGNORE INTO project_statuses (name, sort_order, color, is_closed) VALUES (?, ?, ?, ?)',
[name, sort, color, closed]
);
}
const activityStatuses = [
['Pendiente', 1, 'muted', 0],
['En progreso', 2, 'accent', 0],
['En revision', 3, 'accent', 0],
['Bloqueada', 4, 'danger', 0],
['En espera', 5, 'warning', 0],
['Completada', 6, 'success', 1],
['Cancelada', 7, 'muted', 1],
];
for (const [name, sort, color, closed] of activityStatuses) {
await db.query(
'INSERT IGNORE INTO activity_statuses (name, sort_order, color, is_closed) VALUES (?, ?, ?, ?)',
[name, sort, color, closed]
);
}
const projectTypes = ['Infraestructura', 'Desarrollo de software', 'Soporte', 'Seguridad', 'Mejora interna'];
for (const name of projectTypes) {
await db.query('INSERT IGNORE INTO project_types (name) VALUES (?)', [name]);
}
const departments = [
['TI', null],
['Finanzas', null],
['RRHH', null],
['Comercial', null],
];
const deptIds = {};
for (const [name] of departments) {
await db.query('INSERT IGNORE INTO departments (name, parent_id) VALUES (?, NULL)', [name]);
const [rows] = await db.query('SELECT id FROM departments WHERE name = ? AND parent_id IS NULL', [name]);
deptIds[name] = rows[0].id;
}
const subDepartments = ['Desarrollo', 'Infraestructura', 'Soporte', 'Seguridad'];
for (const name of subDepartments) {
await db.query('INSERT IGNORE INTO departments (name, parent_id) VALUES (?, ?)', [name, deptIds['TI']]);
}
// Usuario admin de dev — convencion del grupo: clave = usuario, SOLO en entornos no productivos.
const [adminRole] = await db.query("SELECT id FROM roles WHERE code = 'admin'");
const [tiDept] = await db.query("SELECT id FROM departments WHERE name = 'TI' AND parent_id IS NULL");
const passwordHash = await bcrypt.hash('mgonzalez', 12);
await db.query(
`INSERT INTO users (name, email, password_hash, role_id, department_id, position, is_active)
VALUES (?, ?, ?, ?, ?, ?, 1)
ON DUPLICATE KEY UPDATE name = VALUES(name)`,
['Milton Gonzalez', 'mgonzalez@linkedcorp.net', passwordHash, adminRole[0].id, tiDept[0].id, 'Jefe de TI']
);
console.log('[seed] listo. Usuario dev: mgonzalez@linkedcorp.net / clave: mgonzalez');
await db.end();
}
run().catch((err) => {
console.error('[seed] error:', err.message);
process.exit(1);
});
+6
View File
@@ -0,0 +1,6 @@
import { app } from './app.js';
import { env } from './config/env.js';
app.listen(env.port, () => {
console.log(`[server] project.linkedcorp.net backend escuchando en :${env.port} (${env.nodeEnv})`);
});
+8
View File
@@ -0,0 +1,8 @@
import { pool } from '../config/db.js';
export async function logAudit({ userId, action, entityType, entityId, detail }) {
await pool.query(
`INSERT INTO audit_log (user_id, action, entity_type, entity_id, detail_json) VALUES (?, ?, ?, ?, ?)`,
[userId, action, entityType, entityId ?? null, detail ? JSON.stringify(detail) : null]
);
}
+4
View File
@@ -0,0 +1,4 @@
// Genera codigos legibles tipo PRY-0007 / ACT-0042 a partir del ultimo id insertado.
export function buildCode(prefix, id) {
return `${prefix}-${String(id).padStart(4, '0')}`;
}
+19
View File
@@ -0,0 +1,19 @@
// Estado de vencimiento calculado al vuelo (no se guarda en BD).
// Reglas del SPEC: vence en >3 dias = normal, <=3 dias = advertencia, hoy = urgente, vencido = critico.
export function dueStatus(dueDate, isDone = false) {
if (!dueDate || isDone) return 'none';
const today = new Date();
today.setHours(0, 0, 0, 0);
const due = new Date(dueDate);
due.setHours(0, 0, 0, 0);
const diffDays = Math.round((due - today) / 86400000);
if (diffDays < 0) return 'overdue';
if (diffDays === 0) return 'today';
if (diffDays <= 3) return 'warning';
return 'on_time';
}
export function todayMysql() {
return new Date().toISOString().slice(0, 10);
}
+18
View File
@@ -0,0 +1,18 @@
export function ok(res, data = null, status = 200) {
return res.status(status).json({ success: true, data, error: null });
}
export function fail(res, status, message) {
return res.status(status).json({ success: false, data: null, error: message });
}
export class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
export function asyncHandler(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}