chore: estructura inicial del MVP
Backend (Express + MariaDB) y frontend (React + Vite, identidad Fluent/Windows) con el nucleo funcional: seguridad, organizacion, proyectos, actividades, subtareas, dependencias, dashboard, calendario, gantt, comentarios, adjuntos, notificaciones in-app y catalogos. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
PORT=4019
|
||||
NODE_ENV=development
|
||||
|
||||
DB_HOST=192.168.222.20
|
||||
DB_PORT=3306
|
||||
DB_NAME=project_linkedcorp
|
||||
DB_USER=project_linkedcorp
|
||||
DB_PASSWORD=
|
||||
|
||||
JWT_SECRET=
|
||||
JWT_EXPIRES=8h
|
||||
|
||||
CORS_ORIGIN=http://localhost:5196
|
||||
|
||||
STORAGE_ROOT=./storage
|
||||
@@ -0,0 +1,196 @@
|
||||
-- project.linkedcorp.net — esquema inicial (MVP). Idempotente: seguro correr sobre BD existente.
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
code VARCHAR(30) NOT NULL UNIQUE,
|
||||
name VARCHAR(80) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS departments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
parent_id INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_departments_parent FOREIGN KEY (parent_id) REFERENCES departments(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
email VARCHAR(160) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(120) NOT NULL,
|
||||
role_id INT NOT NULL,
|
||||
department_id INT NULL,
|
||||
position VARCHAR(120) NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_users_role FOREIGN KEY (role_id) REFERENCES roles(id),
|
||||
CONSTRAINT fk_users_department FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_types (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(80) NOT NULL UNIQUE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS priorities (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(40) NOT NULL UNIQUE,
|
||||
level INT NOT NULL,
|
||||
color VARCHAR(20) NOT NULL DEFAULT 'accent'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_statuses (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(60) NOT NULL UNIQUE,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
color VARCHAR(20) NOT NULL DEFAULT 'accent',
|
||||
is_closed TINYINT(1) NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activity_statuses (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(60) NOT NULL UNIQUE,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
color VARCHAR(20) NOT NULL DEFAULT 'accent',
|
||||
is_closed TINYINT(1) NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
code VARCHAR(20) NOT NULL UNIQUE,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
description TEXT NULL,
|
||||
objective TEXT NULL,
|
||||
project_type_id INT NULL,
|
||||
priority_id INT NOT NULL,
|
||||
status_id INT NOT NULL,
|
||||
owner_user_id INT NOT NULL,
|
||||
requesting_department_id INT NULL,
|
||||
responsible_department_id INT NULL,
|
||||
start_date DATE NULL,
|
||||
estimated_end_date DATE NULL,
|
||||
actual_end_date DATE NULL,
|
||||
progress_pct DECIMAL(5,2) NOT NULL DEFAULT 0,
|
||||
risk_level ENUM('low','medium','high') NOT NULL DEFAULT 'low',
|
||||
notes TEXT NULL,
|
||||
created_by INT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_projects_type FOREIGN KEY (project_type_id) REFERENCES project_types(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_projects_priority FOREIGN KEY (priority_id) REFERENCES priorities(id),
|
||||
CONSTRAINT fk_projects_status FOREIGN KEY (status_id) REFERENCES project_statuses(id),
|
||||
CONSTRAINT fk_projects_owner FOREIGN KEY (owner_user_id) REFERENCES users(id),
|
||||
CONSTRAINT fk_projects_req_dept FOREIGN KEY (requesting_department_id) REFERENCES departments(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_projects_resp_dept FOREIGN KEY (responsible_department_id) REFERENCES departments(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_projects_created_by FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_members (
|
||||
project_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
role_in_project VARCHAR(60) NULL,
|
||||
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (project_id, user_id),
|
||||
CONSTRAINT fk_pm_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_pm_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id INT NOT NULL,
|
||||
code VARCHAR(20) NOT NULL UNIQUE,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
description TEXT NULL,
|
||||
responsible_user_id INT NULL,
|
||||
priority_id INT NOT NULL,
|
||||
status_id INT NOT NULL,
|
||||
start_date DATE NULL,
|
||||
due_date DATE NULL,
|
||||
actual_end_date DATE NULL,
|
||||
progress_pct DECIMAL(5,2) NOT NULL DEFAULT 0,
|
||||
is_milestone TINYINT(1) NOT NULL DEFAULT 0,
|
||||
estimated_hours DECIMAL(6,2) NULL,
|
||||
created_by INT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_activities_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_activities_responsible FOREIGN KEY (responsible_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_activities_priority FOREIGN KEY (priority_id) REFERENCES priorities(id),
|
||||
CONSTRAINT fk_activities_status FOREIGN KEY (status_id) REFERENCES activity_statuses(id),
|
||||
CONSTRAINT fk_activities_created_by FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activity_dependencies (
|
||||
activity_id INT NOT NULL,
|
||||
depends_on_activity_id INT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (activity_id, depends_on_activity_id),
|
||||
CONSTRAINT fk_dep_activity FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_dep_depends_on FOREIGN KEY (depends_on_activity_id) REFERENCES activities(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subtasks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
activity_id INT NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
responsible_user_id INT NULL,
|
||||
is_done TINYINT(1) NOT NULL DEFAULT 0,
|
||||
due_date DATE NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_subtasks_activity FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_subtasks_responsible FOREIGN KEY (responsible_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
entity_type ENUM('project','activity') NOT NULL,
|
||||
entity_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_comments_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
INDEX idx_comments_entity (entity_type, entity_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
entity_type ENUM('project','activity') NOT NULL,
|
||||
entity_id INT NOT NULL,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
original_name VARCHAR(255) NOT NULL,
|
||||
mime_type VARCHAR(120) NOT NULL,
|
||||
size INT NOT NULL,
|
||||
uploaded_by INT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_attachments_user FOREIGN KEY (uploaded_by) REFERENCES users(id),
|
||||
INDEX idx_attachments_entity (entity_type, entity_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
type VARCHAR(60) NOT NULL,
|
||||
message VARCHAR(255) NOT NULL,
|
||||
entity_type ENUM('project','activity') NULL,
|
||||
entity_id INT NULL,
|
||||
is_read TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_notifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_notifications_user (user_id, is_read)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NULL,
|
||||
action VARCHAR(60) NOT NULL,
|
||||
entity_type VARCHAR(40) NOT NULL,
|
||||
entity_id INT NULL,
|
||||
detail_json JSON NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_audit_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_audit_entity (entity_type, entity_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
Generated
+1248
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/server.js",
|
||||
"start": "node src/server.js",
|
||||
"migrate": "node src/scripts/migrate-db.js",
|
||||
"seed": "node src/scripts/seed-db.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.3.0",
|
||||
"mysql2": "^3.11.0"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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];
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { getActivity, putActivity, postDependency, deleteDependency } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/:id', getActivity);
|
||||
router.put('/:id', putActivity);
|
||||
router.post('/:id/dependencies', postDependency);
|
||||
router.delete('/:id/dependencies/:dependsOnId', deleteDependency);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,130 @@
|
||||
import * as repo from './repository.js';
|
||||
import * as projectsRepo from '../projects/repository.js';
|
||||
import { recalcProjectProgress, canWriteProject } from '../projects/service.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
import { ROLES } from '../../middleware/auth.js';
|
||||
import { createNotification } from '../notifications/service.js';
|
||||
|
||||
async function getProjectOr404(projectId) {
|
||||
const project = await projectsRepo.findById(projectId);
|
||||
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
|
||||
return project;
|
||||
}
|
||||
|
||||
// admin/it_manager siempre; project_lead si tiene acceso al proyecto; collaborator solo si es
|
||||
// el responsable de la actividad (no puede crear actividades nuevas ni reasignarlas a otros).
|
||||
export async function canWriteActivity(user, project, activity) {
|
||||
if (user.roleCode === ROLES.ADMIN || user.roleCode === ROLES.IT_MANAGER) return true;
|
||||
if (user.roleCode === ROLES.PROJECT_LEAD) return canWriteProject(user, project);
|
||||
if (user.roleCode === ROLES.COLLABORATOR && activity) return activity.responsible_user_id === user.id;
|
||||
return false;
|
||||
}
|
||||
|
||||
export const listByProject = (projectId) => repo.findByProject(projectId);
|
||||
|
||||
export async function getActivity(id) {
|
||||
const activity = await repo.findById(id);
|
||||
if (!activity) throw new HttpError(404, 'Actividad no encontrada');
|
||||
const dependencies = await repo.listDependencies(id);
|
||||
return { ...activity, dependencies };
|
||||
}
|
||||
|
||||
export async function createActivity(projectId, payload, currentUser) {
|
||||
const project = await getProjectOr404(projectId);
|
||||
if (!(await canWriteProject(currentUser, project))) {
|
||||
throw new HttpError(403, 'No tienes permisos para crear actividades en este proyecto');
|
||||
}
|
||||
if (!payload.name || !payload.priorityId || !payload.statusId) {
|
||||
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
|
||||
}
|
||||
const activity = await repo.create(projectId, { ...payload, createdBy: currentUser.id });
|
||||
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'activity', entityId: activity.id });
|
||||
|
||||
if (activity.responsible_user_id && activity.responsible_user_id !== currentUser.id) {
|
||||
await createNotification({
|
||||
userId: activity.responsible_user_id,
|
||||
type: 'activity_assigned',
|
||||
message: `Te asignaron la actividad "${activity.name}"`,
|
||||
entityType: 'activity',
|
||||
entityId: activity.id,
|
||||
});
|
||||
}
|
||||
await recalcProjectProgress(projectId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
export async function updateActivity(id, payload, currentUser) {
|
||||
const activity = await repo.findById(id);
|
||||
if (!activity) throw new HttpError(404, 'Actividad no encontrada');
|
||||
const project = await getProjectOr404(activity.project_id);
|
||||
if (!(await canWriteActivity(currentUser, project, activity))) {
|
||||
throw new HttpError(403, 'No tienes permisos sobre esta actividad');
|
||||
}
|
||||
if (!payload.name || !payload.priorityId || !payload.statusId) {
|
||||
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
|
||||
}
|
||||
|
||||
// Colaborador no puede reasignar la actividad a otra persona ni cambiar avance manual si hay subtareas.
|
||||
const nextResponsible = currentUser.roleCode === ROLES.COLLABORATOR
|
||||
? activity.responsible_user_id
|
||||
: (payload.responsibleUserId ?? activity.responsible_user_id);
|
||||
|
||||
const subtaskCount = await repo.countSubtasks(id);
|
||||
const updated = await repo.update(id, { ...payload, responsibleUserId: nextResponsible });
|
||||
|
||||
if (subtaskCount === 0 && payload.progressPct !== undefined) {
|
||||
const pct = Math.max(0, Math.min(100, Number(payload.progressPct)));
|
||||
await repo.updateProgress(id, pct);
|
||||
updated.progress_pct = pct;
|
||||
}
|
||||
|
||||
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'activity', entityId: id });
|
||||
|
||||
if (nextResponsible && nextResponsible !== activity.responsible_user_id && nextResponsible !== currentUser.id) {
|
||||
await createNotification({
|
||||
userId: nextResponsible,
|
||||
type: 'activity_assigned',
|
||||
message: `Te asignaron la actividad "${updated.name}"`,
|
||||
entityType: 'activity',
|
||||
entityId: id,
|
||||
});
|
||||
}
|
||||
|
||||
await recalcProjectProgress(activity.project_id);
|
||||
return repo.findById(id);
|
||||
}
|
||||
|
||||
export async function addDependency(activityId, dependsOnActivityId, currentUser) {
|
||||
if (activityId === dependsOnActivityId) throw new HttpError(400, 'Una actividad no puede depender de si misma');
|
||||
const activity = await repo.findById(activityId);
|
||||
if (!activity) throw new HttpError(404, 'Actividad no encontrada');
|
||||
const dependsOn = await repo.findById(dependsOnActivityId);
|
||||
if (!dependsOn || dependsOn.project_id !== activity.project_id) {
|
||||
throw new HttpError(400, 'La dependencia debe ser una actividad del mismo proyecto');
|
||||
}
|
||||
const project = await getProjectOr404(activity.project_id);
|
||||
if (!(await canWriteActivity(currentUser, project, activity))) throw new HttpError(403, 'No tienes permisos sobre esta actividad');
|
||||
const deps = await repo.addDependency(activityId, dependsOnActivityId);
|
||||
await logAudit({ userId: currentUser.id, action: 'add_dependency', entityType: 'activity', entityId: activityId, detail: { dependsOnActivityId } });
|
||||
return deps;
|
||||
}
|
||||
|
||||
export async function removeDependency(activityId, dependsOnActivityId, currentUser) {
|
||||
const activity = await repo.findById(activityId);
|
||||
if (!activity) throw new HttpError(404, 'Actividad no encontrada');
|
||||
const project = await getProjectOr404(activity.project_id);
|
||||
if (!(await canWriteActivity(currentUser, project, activity))) throw new HttpError(403, 'No tienes permisos sobre esta actividad');
|
||||
const deps = await repo.removeDependency(activityId, dependsOnActivityId);
|
||||
await logAudit({ userId: currentUser.id, action: 'remove_dependency', entityType: 'activity', entityId: activityId, detail: { dependsOnActivityId } });
|
||||
return deps;
|
||||
}
|
||||
|
||||
// Recalcula el avance de una actividad a partir de sus subtareas (si tiene) y propaga al proyecto.
|
||||
export async function recalcFromSubtasks(activityId, doneCount, totalCount) {
|
||||
if (totalCount === 0) return;
|
||||
const pct = Math.round((doneCount / totalCount) * 10000) / 100;
|
||||
await repo.updateProgress(activityId, pct);
|
||||
const activity = await repo.findById(activityId);
|
||||
await recalcProjectProgress(activity.project_id);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { ok, fail, asyncHandler, HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
import { ROLES } from '../../middleware/auth.js';
|
||||
import { uploadsDir } from '../../middleware/upload.js';
|
||||
import * as repo from './repository.js';
|
||||
|
||||
const VALID_ENTITY_TYPES = new Set(['project', 'activity']);
|
||||
|
||||
export const getAttachments = asyncHandler(async (req, res) => {
|
||||
const { entityType, entityId } = req.query;
|
||||
if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) return fail(res, 400, 'entityType y entityId son obligatorios');
|
||||
ok(res, await repo.findByEntity(entityType, Number(entityId)));
|
||||
});
|
||||
|
||||
export const postAttachment = asyncHandler(async (req, res) => {
|
||||
const { entityType, entityId } = req.body || {};
|
||||
if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) throw new HttpError(400, 'entityType y entityId son obligatorios');
|
||||
if (req.currentUser.roleCode === ROLES.VIEWER) throw new HttpError(403, 'Tu rol es de solo lectura');
|
||||
if (!req.file) throw new HttpError(400, 'Archivo obligatorio');
|
||||
|
||||
const attachment = await repo.create({
|
||||
entityType, entityId: Number(entityId),
|
||||
filename: req.file.filename, originalName: req.file.originalname,
|
||||
mimeType: req.file.mimetype, size: req.file.size, uploadedBy: req.currentUser.id,
|
||||
});
|
||||
await logAudit({ userId: req.currentUser.id, action: 'upload', entityType, entityId: Number(entityId), detail: { attachmentId: attachment.id } });
|
||||
ok(res, attachment, 201);
|
||||
});
|
||||
|
||||
export const downloadAttachment = asyncHandler(async (req, res) => {
|
||||
const attachment = await repo.findById(Number(req.params.id));
|
||||
if (!attachment) throw new HttpError(404, 'Adjunto no encontrado');
|
||||
const filePath = path.join(uploadsDir, attachment.filename);
|
||||
if (!fs.existsSync(filePath)) throw new HttpError(404, 'Archivo no encontrado en el servidor');
|
||||
res.download(filePath, attachment.original_name);
|
||||
});
|
||||
|
||||
export const deleteAttachment = asyncHandler(async (req, res) => {
|
||||
const attachment = await repo.findById(Number(req.params.id));
|
||||
if (!attachment) throw new HttpError(404, 'Adjunto no encontrado');
|
||||
const canDelete = [ROLES.ADMIN, ROLES.IT_MANAGER].includes(req.currentUser.roleCode) || attachment.uploaded_by === req.currentUser.id;
|
||||
if (!canDelete) throw new HttpError(403, 'No puedes eliminar este adjunto');
|
||||
|
||||
await repo.remove(attachment.id);
|
||||
const filePath = path.join(uploadsDir, attachment.filename);
|
||||
fs.promises.unlink(filePath).catch(() => {});
|
||||
await logAudit({ userId: req.currentUser.id, action: 'delete', entityType: attachment.entity_type, entityId: attachment.entity_id, detail: { attachmentId: attachment.id } });
|
||||
ok(res, { deleted: true });
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function findByEntity(entityType, entityId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT a.id, a.entity_type, a.entity_id, a.filename, a.original_name, a.mime_type, a.size,
|
||||
a.uploaded_by, u.name AS uploaded_by_name, a.created_at
|
||||
FROM attachments a JOIN users u ON u.id = a.uploaded_by
|
||||
WHERE a.entity_type = ? AND a.entity_id = ? ORDER BY a.created_at DESC`,
|
||||
[entityType, entityId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function findById(id) {
|
||||
const [rows] = await pool.query('SELECT * FROM attachments WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function create({ entityType, entityId, filename, originalName, mimeType, size, uploadedBy }) {
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO attachments (entity_type, entity_id, filename, original_name, mime_type, size, uploaded_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[entityType, entityId, filename, originalName, mimeType, size, uploadedBy]
|
||||
);
|
||||
return findById(result.insertId);
|
||||
}
|
||||
|
||||
export async function remove(id) {
|
||||
await pool.query('DELETE FROM attachments WHERE id = ?', [id]);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { upload } from '../../middleware/upload.js';
|
||||
import { getAttachments, postAttachment, downloadAttachment, deleteAttachment } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/', getAttachments);
|
||||
router.post('/', upload.single('file'), postAttachment);
|
||||
router.get('/:id/download', downloadAttachment);
|
||||
router.delete('/:id', deleteAttachment);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as repo from './repository.js';
|
||||
|
||||
export const getAudit = asyncHandler(async (req, res) => {
|
||||
const { entityType, entityId } = req.query;
|
||||
ok(res, await repo.findAll({ entityType, entityId: entityId ? Number(entityId) : undefined }));
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function findAll({ entityType, entityId } = {}) {
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
if (entityType) { clauses.push('a.entity_type = ?'); params.push(entityType); }
|
||||
if (entityId) { clauses.push('a.entity_id = ?'); params.push(entityId); }
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const [rows] = await pool.query(
|
||||
`SELECT a.id, a.user_id, u.name AS user_name, a.action, a.entity_type, a.entity_id, a.detail_json, a.created_at
|
||||
FROM audit_log a LEFT JOIN users u ON u.id = a.user_id
|
||||
${where} ORDER BY a.created_at DESC LIMIT 200`,
|
||||
params
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js';
|
||||
import { getAudit } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth, requireRole(ROLES.ADMIN, ROLES.IT_MANAGER));
|
||||
router.get('/', getAudit);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ok, fail, asyncHandler } from '../../utils/http.js';
|
||||
import * as authService from './service.js';
|
||||
|
||||
export const postLogin = asyncHandler(async (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!email || !password) return fail(res, 400, 'Email y clave son obligatorios');
|
||||
const result = await authService.login(email, password);
|
||||
ok(res, result);
|
||||
});
|
||||
|
||||
export const postLogout = asyncHandler(async (req, res) => {
|
||||
// Stateless JWT: el cliente descarta el token. Se deja el endpoint para uso futuro (blacklist).
|
||||
ok(res, { loggedOut: true });
|
||||
});
|
||||
|
||||
export const getMe = asyncHandler(async (req, res) => {
|
||||
ok(res, req.currentUser);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { postLogin, postLogout, getMe } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
limit: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
router.post('/login', loginLimiter, postLogin);
|
||||
router.post('/logout', requireAuth, postLogout);
|
||||
router.get('/me', requireAuth, getMe);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,36 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { pool } from '../../config/db.js';
|
||||
import { env } from '../../config/env.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
|
||||
export async function login(email, password) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.department_id, r.code AS role_code, r.name AS role_name
|
||||
FROM users u JOIN roles r ON r.id = u.role_id
|
||||
WHERE u.email = ? LIMIT 1`,
|
||||
[email]
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user || !user.is_active) throw new HttpError(401, 'Credenciales invalidas');
|
||||
|
||||
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||||
if (!validPassword) throw new HttpError(401, 'Credenciales invalidas');
|
||||
|
||||
const token = jwt.sign({ sub: user.id }, env.jwtSecret, { expiresIn: env.jwtExpires });
|
||||
|
||||
await logAudit({ userId: user.id, action: 'login', entityType: 'auth', entityId: user.id });
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
roleCode: user.role_code,
|
||||
roleName: user.role_name,
|
||||
departmentId: user.department_id,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ok, fail, asyncHandler } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
import { CATALOGS } from './repository.js';
|
||||
import * as repo from './repository.js';
|
||||
|
||||
function validKey(req, res) {
|
||||
const key = req.params.catalog;
|
||||
if (!CATALOGS[key]) {
|
||||
fail(res, 404, 'Catalogo no encontrado');
|
||||
return null;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export const getCatalog = asyncHandler(async (req, res) => {
|
||||
const key = validKey(req, res);
|
||||
if (!key) return;
|
||||
ok(res, await repo.findAll(key));
|
||||
});
|
||||
|
||||
export const postCatalog = asyncHandler(async (req, res) => {
|
||||
const key = validKey(req, res);
|
||||
if (!key) return;
|
||||
const item = await repo.create(key, req.body);
|
||||
await logAudit({ userId: req.currentUser.id, action: 'create', entityType: `catalog:${key}`, entityId: item.id });
|
||||
ok(res, item, 201);
|
||||
});
|
||||
|
||||
export const putCatalog = asyncHandler(async (req, res) => {
|
||||
const key = validKey(req, res);
|
||||
if (!key) return;
|
||||
const item = await repo.update(key, Number(req.params.id), req.body);
|
||||
await logAudit({ userId: req.currentUser.id, action: 'update', entityType: `catalog:${key}`, entityId: item.id });
|
||||
ok(res, item);
|
||||
});
|
||||
|
||||
export const deleteCatalog = asyncHandler(async (req, res) => {
|
||||
const key = validKey(req, res);
|
||||
if (!key) return;
|
||||
await repo.remove(key, Number(req.params.id));
|
||||
await logAudit({ userId: req.currentUser.id, action: 'delete', entityType: `catalog:${key}`, entityId: Number(req.params.id) });
|
||||
ok(res, { deleted: true });
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
// Whitelist de catalogos editables — nunca aceptar el nombre de tabla desde el cliente.
|
||||
export const CATALOGS = {
|
||||
'project-types': { table: 'project_types', fields: ['name'], order: 'name' },
|
||||
priorities: { table: 'priorities', fields: ['name', 'level', 'color'], order: 'level' },
|
||||
'project-statuses': {
|
||||
table: 'project_statuses',
|
||||
fields: ['name', 'sort_order', 'color', 'is_closed'],
|
||||
order: 'sort_order',
|
||||
},
|
||||
'activity-statuses': {
|
||||
table: 'activity_statuses',
|
||||
fields: ['name', 'sort_order', 'color', 'is_closed'],
|
||||
order: 'sort_order',
|
||||
},
|
||||
};
|
||||
|
||||
export async function findAll(catalogKey) {
|
||||
const cfg = CATALOGS[catalogKey];
|
||||
const [rows] = await pool.query(`SELECT * FROM ${cfg.table} ORDER BY ${cfg.order}`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function create(catalogKey, payload) {
|
||||
const cfg = CATALOGS[catalogKey];
|
||||
const cols = cfg.fields.filter((f) => payload[f] !== undefined);
|
||||
const placeholders = cols.map(() => '?').join(', ');
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO ${cfg.table} (${cols.join(', ')}) VALUES (${placeholders})`,
|
||||
cols.map((c) => payload[c])
|
||||
);
|
||||
return { id: result.insertId, ...payload };
|
||||
}
|
||||
|
||||
export async function update(catalogKey, id, payload) {
|
||||
const cfg = CATALOGS[catalogKey];
|
||||
const cols = cfg.fields.filter((f) => payload[f] !== undefined);
|
||||
const setClause = cols.map((c) => `${c} = ?`).join(', ');
|
||||
await pool.query(`UPDATE ${cfg.table} SET ${setClause} WHERE id = ?`, [...cols.map((c) => payload[c]), id]);
|
||||
return { id, ...payload };
|
||||
}
|
||||
|
||||
export async function remove(catalogKey, id) {
|
||||
const cfg = CATALOGS[catalogKey];
|
||||
await pool.query(`DELETE FROM ${cfg.table} WHERE id = ?`, [id]);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth, requireRole, CAN_MANAGE_CATALOGS } from '../../middleware/auth.js';
|
||||
import { getCatalog, postCatalog, putCatalog, deleteCatalog } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/:catalog', getCatalog);
|
||||
router.post('/:catalog', requireRole(...CAN_MANAGE_CATALOGS), postCatalog);
|
||||
router.put('/:catalog/:id', requireRole(...CAN_MANAGE_CATALOGS), putCatalog);
|
||||
router.delete('/:catalog/:id', requireRole(...CAN_MANAGE_CATALOGS), deleteCatalog);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ok, fail, asyncHandler } from '../../utils/http.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
import { ROLES } from '../../middleware/auth.js';
|
||||
import * as repo from './repository.js';
|
||||
|
||||
const VALID_ENTITY_TYPES = new Set(['project', 'activity']);
|
||||
|
||||
export const getComments = asyncHandler(async (req, res) => {
|
||||
const { entityType, entityId } = req.query;
|
||||
if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) return fail(res, 400, 'entityType y entityId son obligatorios');
|
||||
ok(res, await repo.findByEntity(entityType, Number(entityId)));
|
||||
});
|
||||
|
||||
export const postComment = asyncHandler(async (req, res) => {
|
||||
const { entityType, entityId, message } = req.body || {};
|
||||
if (!VALID_ENTITY_TYPES.has(entityType) || !entityId || !message) {
|
||||
throw new HttpError(400, 'entityType, entityId y message son obligatorios');
|
||||
}
|
||||
if (req.currentUser.roleCode === ROLES.VIEWER) throw new HttpError(403, 'Tu rol es de solo lectura');
|
||||
const comment = await repo.create({ entityType, entityId, userId: req.currentUser.id, message });
|
||||
await logAudit({ userId: req.currentUser.id, action: 'comment', entityType, entityId, detail: { commentId: comment.id } });
|
||||
ok(res, comment, 201);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function findByEntity(entityType, entityId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.created_at
|
||||
FROM comments c JOIN users u ON u.id = c.user_id
|
||||
WHERE c.entity_type = ? AND c.entity_id = ? ORDER BY c.created_at`,
|
||||
[entityType, entityId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function create({ entityType, entityId, userId, message }) {
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO comments (entity_type, entity_id, user_id, message) VALUES (?, ?, ?, ?)',
|
||||
[entityType, entityId, userId, message]
|
||||
);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.created_at
|
||||
FROM comments c JOIN users u ON u.id = c.user_id WHERE c.id = ?`,
|
||||
[result.insertId]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { getComments, postComment } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/', getComments);
|
||||
router.post('/', postComment);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ok, fail, asyncHandler } from '../../utils/http.js';
|
||||
import * as repo from './repository.js';
|
||||
import { listByProject } from '../activities/service.js';
|
||||
import { dueStatus } from '../../utils/dates.js';
|
||||
|
||||
export const getSummary = asyncHandler(async (req, res) => {
|
||||
const [kpis, myActivities, byStatus] = await Promise.all([
|
||||
repo.summary(),
|
||||
repo.myPendingActivities(req.currentUser.id),
|
||||
repo.projectsByStatus(),
|
||||
]);
|
||||
ok(res, {
|
||||
kpis,
|
||||
myActivities: myActivities.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date) })),
|
||||
projectsByStatus: byStatus,
|
||||
});
|
||||
});
|
||||
|
||||
export const getCalendar = asyncHandler(async (req, res) => {
|
||||
const month = req.query.month; // formato YYYY-MM
|
||||
if (!month || !/^\d{4}-\d{2}$/.test(month)) return fail(res, 400, 'Parametro month invalido (YYYY-MM)');
|
||||
const start = `${month}-01`;
|
||||
const [year, m] = month.split('-').map(Number);
|
||||
const end = new Date(year, m, 0).toISOString().slice(0, 10);
|
||||
const items = await repo.calendarActivities(start, end);
|
||||
ok(res, items.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date, a.status_is_closed) })));
|
||||
});
|
||||
|
||||
export const getGantt = asyncHandler(async (req, res) => {
|
||||
const activities = await listByProject(Number(req.params.id));
|
||||
ok(res, activities.map((a) => ({
|
||||
id: a.id, code: a.code, name: a.name, startDate: a.start_date, dueDate: a.due_date,
|
||||
progressPct: a.progress_pct, isMilestone: !!a.is_milestone, responsibleName: a.responsible_name,
|
||||
})));
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function summary() {
|
||||
const [[activeProjects]] = await pool.query(
|
||||
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id WHERE ps.is_closed = 0`
|
||||
);
|
||||
const [[overdueProjects]] = await pool.query(
|
||||
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id
|
||||
WHERE ps.is_closed = 0 AND p.estimated_end_date IS NOT NULL AND p.estimated_end_date < CURDATE()`
|
||||
);
|
||||
const [[dueSoonProjects]] = await pool.query(
|
||||
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id
|
||||
WHERE ps.is_closed = 0 AND p.estimated_end_date IS NOT NULL
|
||||
AND p.estimated_end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY)`
|
||||
);
|
||||
const [[pendingActivities]] = await pool.query(
|
||||
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id WHERE st.is_closed = 0`
|
||||
);
|
||||
const [[overdueActivities]] = await pool.query(
|
||||
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id
|
||||
WHERE st.is_closed = 0 AND a.due_date IS NOT NULL AND a.due_date < CURDATE()`
|
||||
);
|
||||
const [[todayActivities]] = await pool.query(
|
||||
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id
|
||||
WHERE st.is_closed = 0 AND a.due_date = CURDATE()`
|
||||
);
|
||||
const [[avgProgress]] = await pool.query(
|
||||
`SELECT AVG(progress_pct) AS avg_pct FROM projects p JOIN project_statuses ps ON ps.id = p.status_id WHERE ps.is_closed = 0`
|
||||
);
|
||||
|
||||
return {
|
||||
activeProjects: activeProjects.c,
|
||||
overdueProjects: overdueProjects.c,
|
||||
dueSoonProjects: dueSoonProjects.c,
|
||||
pendingActivities: pendingActivities.c,
|
||||
overdueActivities: overdueActivities.c,
|
||||
todayActivities: todayActivities.c,
|
||||
averageProgress: avgProgress.avg_pct === null ? 0 : Math.round(Number(avgProgress.avg_pct) * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
export async function myPendingActivities(userId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT a.id, a.code, a.name, a.due_date, a.progress_pct, p.id AS project_id, p.name AS project_name
|
||||
FROM activities a JOIN activity_statuses st ON st.id = a.status_id JOIN projects p ON p.id = a.project_id
|
||||
WHERE st.is_closed = 0 AND a.responsible_user_id = ?
|
||||
ORDER BY (a.due_date IS NULL), a.due_date LIMIT 20`,
|
||||
[userId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function calendarActivities(monthStart, monthEnd) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT a.id, a.code, a.name, a.due_date, a.is_milestone, a.progress_pct,
|
||||
st.is_closed AS status_is_closed, p.id AS project_id, p.name AS project_name
|
||||
FROM activities a JOIN activity_statuses st ON st.id = a.status_id JOIN projects p ON p.id = a.project_id
|
||||
WHERE a.due_date BETWEEN ? AND ?
|
||||
ORDER BY a.due_date`,
|
||||
[monthStart, monthEnd]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function projectsByStatus() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT ps.name, ps.color, COUNT(p.id) AS total
|
||||
FROM project_statuses ps LEFT JOIN projects p ON p.status_id = ps.id
|
||||
GROUP BY ps.id ORDER BY ps.sort_order`
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { getSummary, getCalendar } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/summary', getSummary);
|
||||
router.get('/calendar', getCalendar);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as service from './service.js';
|
||||
|
||||
export const getDepartments = asyncHandler(async (req, res) => ok(res, await service.listTree()));
|
||||
|
||||
export const postDepartment = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.createDepartment(req.body, req.currentUser.id), 201);
|
||||
});
|
||||
|
||||
export const putDepartment = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.updateDepartment(Number(req.params.id), req.body, req.currentUser.id));
|
||||
});
|
||||
|
||||
export const deleteDepartment = asyncHandler(async (req, res) => {
|
||||
await service.deleteDepartment(Number(req.params.id), req.currentUser.id);
|
||||
ok(res, { deleted: true });
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function findAllFlat() {
|
||||
const [rows] = await pool.query('SELECT id, name, parent_id FROM departments ORDER BY name');
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function create({ name, parentId }) {
|
||||
const [result] = await pool.query('INSERT INTO departments (name, parent_id) VALUES (?, ?)', [
|
||||
name,
|
||||
parentId || null,
|
||||
]);
|
||||
return { id: result.insertId, name, parent_id: parentId || null };
|
||||
}
|
||||
|
||||
export async function update(id, { name, parentId }) {
|
||||
await pool.query('UPDATE departments SET name = ?, parent_id = ? WHERE id = ?', [name, parentId || null, id]);
|
||||
return { id, name, parent_id: parentId || null };
|
||||
}
|
||||
|
||||
export async function remove(id) {
|
||||
await pool.query('DELETE FROM departments WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
export async function hasChildren(id) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS c FROM departments WHERE parent_id = ?', [id]);
|
||||
return rows[0].c > 0;
|
||||
}
|
||||
|
||||
export async function hasUsers(id) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS c FROM users WHERE department_id = ?', [id]);
|
||||
return rows[0].c > 0;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth, requireRole, CAN_MANAGE_USERS } from '../../middleware/auth.js';
|
||||
import { getDepartments, postDepartment, putDepartment, deleteDepartment } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/', getDepartments);
|
||||
router.post('/', requireRole(...CAN_MANAGE_USERS), postDepartment);
|
||||
router.put('/:id', requireRole(...CAN_MANAGE_USERS), putDepartment);
|
||||
router.delete('/:id', requireRole(...CAN_MANAGE_USERS), deleteDepartment);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as repo from './repository.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
|
||||
function toTree(flat) {
|
||||
const byId = new Map(flat.map((d) => [d.id, { ...d, children: [] }]));
|
||||
const roots = [];
|
||||
for (const dept of byId.values()) {
|
||||
if (dept.parent_id && byId.has(dept.parent_id)) {
|
||||
byId.get(dept.parent_id).children.push(dept);
|
||||
} else {
|
||||
roots.push(dept);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
export async function listTree() {
|
||||
const flat = await repo.findAllFlat();
|
||||
return toTree(flat);
|
||||
}
|
||||
|
||||
export async function createDepartment(payload, actorId) {
|
||||
if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio');
|
||||
const dept = await repo.create(payload);
|
||||
await logAudit({ userId: actorId, action: 'create', entityType: 'department', entityId: dept.id });
|
||||
return dept;
|
||||
}
|
||||
|
||||
export async function updateDepartment(id, payload, actorId) {
|
||||
if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio');
|
||||
if (Number(payload.parentId) === Number(id)) throw new HttpError(400, 'Un departamento no puede ser su propio padre');
|
||||
const dept = await repo.update(id, payload);
|
||||
await logAudit({ userId: actorId, action: 'update', entityType: 'department', entityId: id });
|
||||
return dept;
|
||||
}
|
||||
|
||||
export async function deleteDepartment(id, actorId) {
|
||||
if (await repo.hasChildren(id)) throw new HttpError(400, 'Tiene sub-departamentos; reasignalos antes de eliminar');
|
||||
if (await repo.hasUsers(id)) throw new HttpError(400, 'Tiene usuarios asignados; reasignalos antes de eliminar');
|
||||
await repo.remove(id);
|
||||
await logAudit({ userId: actorId, action: 'delete', entityType: 'department', entityId: id });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as service from './service.js';
|
||||
|
||||
export const getNotifications = asyncHandler(async (req, res) => {
|
||||
const [items, unread] = await Promise.all([
|
||||
service.listMine(req.currentUser.id),
|
||||
service.countUnread(req.currentUser.id),
|
||||
]);
|
||||
ok(res, { items, unread });
|
||||
});
|
||||
|
||||
export const patchNotificationRead = asyncHandler(async (req, res) => {
|
||||
await service.markRead(Number(req.params.id), req.currentUser.id);
|
||||
ok(res, { read: true });
|
||||
});
|
||||
|
||||
export const patchAllRead = asyncHandler(async (req, res) => {
|
||||
await service.markAllRead(req.currentUser.id);
|
||||
ok(res, { read: true });
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function findByUser(userId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, type, message, entity_type, entity_id, is_read, created_at
|
||||
FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`,
|
||||
[userId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function countUnread(userId) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS c FROM notifications WHERE user_id = ? AND is_read = 0', [userId]);
|
||||
return rows[0].c;
|
||||
}
|
||||
|
||||
export async function create({ userId, type, message, entityType, entityId }) {
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO notifications (user_id, type, message, entity_type, entity_id) VALUES (?, ?, ?, ?, ?)',
|
||||
[userId, type, message, entityType || null, entityId || null]
|
||||
);
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
export async function markRead(id, userId) {
|
||||
await pool.query('UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?', [id, userId]);
|
||||
}
|
||||
|
||||
export async function markAllRead(userId) {
|
||||
await pool.query('UPDATE notifications SET is_read = 1 WHERE user_id = ?', [userId]);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { getNotifications, patchNotificationRead, patchAllRead } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/', getNotifications);
|
||||
router.patch('/read-all', patchAllRead);
|
||||
router.patch('/:id/read', patchNotificationRead);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as repo from './repository.js';
|
||||
|
||||
export const listMine = (userId) => repo.findByUser(userId);
|
||||
export const countUnread = (userId) => repo.countUnread(userId);
|
||||
export const createNotification = (payload) => repo.create(payload);
|
||||
export const markRead = (id, userId) => repo.markRead(id, userId);
|
||||
export const markAllRead = (userId) => repo.markAllRead(userId);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as service from './service.js';
|
||||
|
||||
export const getProjects = asyncHandler(async (req, res) => {
|
||||
const { statusId, priorityId, ownerUserId } = req.query;
|
||||
ok(res, await service.listProjects({ statusId, priorityId, ownerUserId }));
|
||||
});
|
||||
|
||||
export const getProject = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.getProject(Number(req.params.id)));
|
||||
});
|
||||
|
||||
export const postProject = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.createProject(req.body, req.currentUser), 201);
|
||||
});
|
||||
|
||||
export const putProject = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.updateProject(Number(req.params.id), req.body, req.currentUser));
|
||||
});
|
||||
|
||||
export const postMember = asyncHandler(async (req, res) => {
|
||||
const members = await service.addMember(Number(req.params.id), Number(req.body.userId), req.body.roleInProject, req.currentUser);
|
||||
ok(res, members, 201);
|
||||
});
|
||||
|
||||
export const deleteMember = asyncHandler(async (req, res) => {
|
||||
const members = await service.removeMember(Number(req.params.id), Number(req.params.userId), req.currentUser);
|
||||
ok(res, members);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
const LIST_SELECT = `
|
||||
SELECT p.id, p.code, p.name, p.description, p.objective, p.project_type_id, pt.name AS project_type_name,
|
||||
p.priority_id, pr.name AS priority_name, pr.color AS priority_color,
|
||||
p.status_id, ps.name AS status_name, ps.color AS status_color, ps.is_closed AS status_is_closed,
|
||||
p.owner_user_id, u.name AS owner_name,
|
||||
p.requesting_department_id, rd.name AS requesting_department_name,
|
||||
p.responsible_department_id, sd.name AS responsible_department_name,
|
||||
p.start_date, p.estimated_end_date, p.actual_end_date, p.progress_pct,
|
||||
p.risk_level, p.notes, p.created_by, p.created_at, p.updated_at
|
||||
FROM projects p
|
||||
JOIN priorities pr ON pr.id = p.priority_id
|
||||
JOIN project_statuses ps ON ps.id = p.status_id
|
||||
JOIN users u ON u.id = p.owner_user_id
|
||||
LEFT JOIN project_types pt ON pt.id = p.project_type_id
|
||||
LEFT JOIN departments rd ON rd.id = p.requesting_department_id
|
||||
LEFT JOIN departments sd ON sd.id = p.responsible_department_id
|
||||
`;
|
||||
|
||||
export async function findAll({ statusId, priorityId, ownerUserId } = {}) {
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
if (statusId) { clauses.push('p.status_id = ?'); params.push(statusId); }
|
||||
if (priorityId) { clauses.push('p.priority_id = ?'); params.push(priorityId); }
|
||||
if (ownerUserId) { clauses.push('p.owner_user_id = ?'); params.push(ownerUserId); }
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.created_at DESC`, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function findById(id) {
|
||||
const [rows] = await pool.query(`${LIST_SELECT} WHERE p.id = ?`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function create(data) {
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO projects
|
||||
(code, name, description, objective, project_type_id, priority_id, status_id, owner_user_id,
|
||||
requesting_department_id, responsible_department_id, start_date, estimated_end_date,
|
||||
risk_level, notes, created_by)
|
||||
VALUES ('PENDING', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
data.name, data.description || null, data.objective || null, data.projectTypeId || null,
|
||||
data.priorityId, data.statusId, data.ownerUserId,
|
||||
data.requestingDepartmentId || null, data.responsibleDepartmentId || null,
|
||||
data.startDate || null, data.estimatedEndDate || null,
|
||||
data.riskLevel || 'low', data.notes || null, data.createdBy,
|
||||
]
|
||||
);
|
||||
const id = result.insertId;
|
||||
const code = `PRY-${String(id).padStart(4, '0')}`;
|
||||
await pool.query('UPDATE projects SET code = ? WHERE id = ?', [code, id]);
|
||||
// El creador queda como miembro por defecto.
|
||||
await pool.query('INSERT IGNORE INTO project_members (project_id, user_id, role_in_project) VALUES (?, ?, ?)', [
|
||||
id, data.ownerUserId, 'owner',
|
||||
]);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function update(id, data) {
|
||||
await pool.query(
|
||||
`UPDATE projects SET
|
||||
name = ?, description = ?, objective = ?, project_type_id = ?, priority_id = ?, status_id = ?,
|
||||
owner_user_id = ?, requesting_department_id = ?, responsible_department_id = ?,
|
||||
start_date = ?, estimated_end_date = ?, actual_end_date = ?, risk_level = ?, notes = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
data.name, data.description || null, data.objective || null, data.projectTypeId || null,
|
||||
data.priorityId, data.statusId, data.ownerUserId,
|
||||
data.requestingDepartmentId || null, data.responsibleDepartmentId || null,
|
||||
data.startDate || null, data.estimatedEndDate || null, data.actualEndDate || null,
|
||||
data.riskLevel || 'low', data.notes || null, id,
|
||||
]
|
||||
);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function updateProgress(id, progressPct) {
|
||||
await pool.query('UPDATE projects SET progress_pct = ? WHERE id = ?', [progressPct, id]);
|
||||
}
|
||||
|
||||
export async function listMembers(projectId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT pm.user_id, u.name, u.email, pm.role_in_project, pm.added_at
|
||||
FROM project_members pm JOIN users u ON u.id = pm.user_id
|
||||
WHERE pm.project_id = ? ORDER BY u.name`,
|
||||
[projectId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function isMember(projectId, userId) {
|
||||
const [rows] = await pool.query('SELECT 1 FROM project_members WHERE project_id = ? AND user_id = ?', [
|
||||
projectId, userId,
|
||||
]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export async function addMember(projectId, userId, roleInProject) {
|
||||
await pool.query(
|
||||
'INSERT IGNORE INTO project_members (project_id, user_id, role_in_project) VALUES (?, ?, ?)',
|
||||
[projectId, userId, roleInProject || null]
|
||||
);
|
||||
return listMembers(projectId);
|
||||
}
|
||||
|
||||
export async function removeMember(projectId, userId) {
|
||||
await pool.query('DELETE FROM project_members WHERE project_id = ? AND user_id = ?', [projectId, userId]);
|
||||
return listMembers(projectId);
|
||||
}
|
||||
|
||||
export async function averageActivityProgress(projectId) {
|
||||
const [rows] = await pool.query('SELECT AVG(progress_pct) AS avg_pct FROM activities WHERE project_id = ?', [
|
||||
projectId,
|
||||
]);
|
||||
return rows[0].avg_pct === null ? 0 : Number(rows[0].avg_pct);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { getProjects, getProject, postProject, putProject, postMember, deleteMember } from './controller.js';
|
||||
import { getGantt } from '../dashboard/controller.js';
|
||||
import { getActivitiesByProject, postActivity } from '../activities/controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/', getProjects);
|
||||
router.post('/', postProject);
|
||||
router.get('/:id', getProject);
|
||||
router.put('/:id', putProject);
|
||||
router.get('/:id/gantt', getGantt);
|
||||
router.post('/:id/members', postMember);
|
||||
router.delete('/:id/members/:userId', deleteMember);
|
||||
router.get('/:projectId/activities', getActivitiesByProject);
|
||||
router.post('/:projectId/activities', postActivity);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as repo from './repository.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
import { ROLES } from '../../middleware/auth.js';
|
||||
|
||||
// Alcance de escritura: admin/it_manager siempre; project_lead solo si es dueño o miembro.
|
||||
export async function canWriteProject(user, project) {
|
||||
if (user.roleCode === ROLES.ADMIN || user.roleCode === ROLES.IT_MANAGER) return true;
|
||||
if (user.roleCode !== ROLES.PROJECT_LEAD) return false;
|
||||
if (project.owner_user_id === user.id) return true;
|
||||
return repo.isMember(project.id, user.id);
|
||||
}
|
||||
|
||||
export const listProjects = (filters) => repo.findAll(filters);
|
||||
|
||||
export async function getProject(id) {
|
||||
const project = await repo.findById(id);
|
||||
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
|
||||
const members = await repo.listMembers(id);
|
||||
return { ...project, members };
|
||||
}
|
||||
|
||||
export async function createProject(payload, currentUser) {
|
||||
if (!payload.name || !payload.priorityId || !payload.statusId) {
|
||||
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
|
||||
}
|
||||
if (![ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD].includes(currentUser.roleCode)) {
|
||||
throw new HttpError(403, 'No tienes permisos para crear proyectos');
|
||||
}
|
||||
const project = await repo.create({
|
||||
...payload,
|
||||
ownerUserId: payload.ownerUserId || currentUser.id,
|
||||
createdBy: currentUser.id,
|
||||
});
|
||||
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'project', entityId: project.id });
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function updateProject(id, payload, currentUser) {
|
||||
const project = await repo.findById(id);
|
||||
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
|
||||
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
|
||||
if (!payload.name || !payload.priorityId || !payload.statusId) {
|
||||
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
|
||||
}
|
||||
const updated = await repo.update(id, { ...payload, ownerUserId: payload.ownerUserId || project.owner_user_id });
|
||||
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'project', entityId: id });
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Recalcula el avance del proyecto como promedio del avance de sus actividades.
|
||||
export async function recalcProjectProgress(projectId) {
|
||||
const avg = await repo.averageActivityProgress(projectId);
|
||||
await repo.updateProgress(projectId, Math.round(avg * 100) / 100);
|
||||
}
|
||||
|
||||
export async function addMember(projectId, userId, roleInProject, currentUser) {
|
||||
const project = await repo.findById(projectId);
|
||||
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
|
||||
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
|
||||
const members = await repo.addMember(projectId, userId, roleInProject);
|
||||
await logAudit({ userId: currentUser.id, action: 'add_member', entityType: 'project', entityId: projectId, detail: { userId } });
|
||||
return members;
|
||||
}
|
||||
|
||||
export async function removeMember(projectId, userId, currentUser) {
|
||||
const project = await repo.findById(projectId);
|
||||
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
|
||||
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
|
||||
const members = await repo.removeMember(projectId, userId);
|
||||
await logAudit({ userId: currentUser.id, action: 'remove_member', entityType: 'project', entityId: projectId, detail: { userId } });
|
||||
return members;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as service from './service.js';
|
||||
|
||||
export const getSubtasks = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.listByActivity(Number(req.params.activityId)));
|
||||
});
|
||||
|
||||
export const postSubtask = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.createSubtask(Number(req.params.activityId), req.body, req.currentUser), 201);
|
||||
});
|
||||
|
||||
export const putSubtask = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.updateSubtask(Number(req.params.id), req.body, req.currentUser));
|
||||
});
|
||||
|
||||
export const patchSubtaskDone = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.setSubtaskDone(Number(req.params.id), !!req.body.isDone, req.currentUser));
|
||||
});
|
||||
|
||||
export const deleteSubtask = asyncHandler(async (req, res) => {
|
||||
await service.removeSubtask(Number(req.params.id), req.currentUser);
|
||||
ok(res, { deleted: true });
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function findByActivity(activityId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT s.id, s.activity_id, s.name, s.responsible_user_id, u.name AS responsible_name,
|
||||
s.is_done, s.due_date, s.created_at
|
||||
FROM subtasks s LEFT JOIN users u ON u.id = s.responsible_user_id
|
||||
WHERE s.activity_id = ? ORDER BY s.created_at`,
|
||||
[activityId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function findById(id) {
|
||||
const [rows] = await pool.query('SELECT * FROM subtasks WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function create(activityId, { name, responsibleUserId, dueDate }) {
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO subtasks (activity_id, name, responsible_user_id, due_date) VALUES (?, ?, ?, ?)',
|
||||
[activityId, name, responsibleUserId || null, dueDate || null]
|
||||
);
|
||||
return findById(result.insertId);
|
||||
}
|
||||
|
||||
export async function update(id, { name, responsibleUserId, dueDate }) {
|
||||
await pool.query('UPDATE subtasks SET name = ?, responsible_user_id = ?, due_date = ? WHERE id = ?', [
|
||||
name, responsibleUserId || null, dueDate || null, id,
|
||||
]);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function setDone(id, isDone) {
|
||||
await pool.query('UPDATE subtasks SET is_done = ? WHERE id = ?', [isDone ? 1 : 0, id]);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function remove(id) {
|
||||
await pool.query('DELETE FROM subtasks WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
export async function countsByActivity(activityId) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT COUNT(*) AS total, SUM(is_done) AS done FROM subtasks WHERE activity_id = ?',
|
||||
[activityId]
|
||||
);
|
||||
return { total: rows[0].total, done: Number(rows[0].done) || 0 };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { getSubtasks, postSubtask, putSubtask, patchSubtaskDone, deleteSubtask } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/activities/:activityId/subtasks', getSubtasks);
|
||||
router.post('/activities/:activityId/subtasks', postSubtask);
|
||||
router.put('/subtasks/:id', putSubtask);
|
||||
router.patch('/subtasks/:id/done', patchSubtaskDone);
|
||||
router.delete('/subtasks/:id', deleteSubtask);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as repo from './repository.js';
|
||||
import * as activitiesRepo from '../activities/repository.js';
|
||||
import * as projectsRepo from '../projects/repository.js';
|
||||
import { canWriteActivity, recalcFromSubtasks } from '../activities/service.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
|
||||
async function requireActivityAccess(activityId, currentUser) {
|
||||
const activity = await activitiesRepo.findById(activityId);
|
||||
if (!activity) throw new HttpError(404, 'Actividad no encontrada');
|
||||
const project = await projectsRepo.findById(activity.project_id);
|
||||
if (!(await canWriteActivity(currentUser, project, activity))) {
|
||||
throw new HttpError(403, 'No tienes permisos sobre esta actividad');
|
||||
}
|
||||
return activity;
|
||||
}
|
||||
|
||||
export const listByActivity = (activityId) => repo.findByActivity(activityId);
|
||||
|
||||
export async function createSubtask(activityId, payload, currentUser) {
|
||||
await requireActivityAccess(activityId, currentUser);
|
||||
if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio');
|
||||
const subtask = await repo.create(activityId, payload);
|
||||
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'subtask', entityId: subtask.id });
|
||||
const counts = await repo.countsByActivity(activityId);
|
||||
await recalcFromSubtasks(activityId, counts.done, counts.total);
|
||||
return subtask;
|
||||
}
|
||||
|
||||
export async function updateSubtask(id, payload, currentUser) {
|
||||
const subtask = await repo.findById(id);
|
||||
if (!subtask) throw new HttpError(404, 'Subtarea no encontrada');
|
||||
await requireActivityAccess(subtask.activity_id, currentUser);
|
||||
if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio');
|
||||
const updated = await repo.update(id, payload);
|
||||
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'subtask', entityId: id });
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function setSubtaskDone(id, isDone, currentUser) {
|
||||
const subtask = await repo.findById(id);
|
||||
if (!subtask) throw new HttpError(404, 'Subtarea no encontrada');
|
||||
await requireActivityAccess(subtask.activity_id, currentUser);
|
||||
const updated = await repo.setDone(id, isDone);
|
||||
await logAudit({ userId: currentUser.id, action: isDone ? 'complete' : 'reopen', entityType: 'subtask', entityId: id });
|
||||
const counts = await repo.countsByActivity(subtask.activity_id);
|
||||
await recalcFromSubtasks(subtask.activity_id, counts.done, counts.total);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function removeSubtask(id, currentUser) {
|
||||
const subtask = await repo.findById(id);
|
||||
if (!subtask) throw new HttpError(404, 'Subtarea no encontrada');
|
||||
await requireActivityAccess(subtask.activity_id, currentUser);
|
||||
await repo.remove(id);
|
||||
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'subtask', entityId: id });
|
||||
const counts = await repo.countsByActivity(subtask.activity_id);
|
||||
await recalcFromSubtasks(subtask.activity_id, counts.done, counts.total);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as service from './service.js';
|
||||
|
||||
export const getUsers = asyncHandler(async (req, res) => ok(res, await service.listUsers()));
|
||||
export const getRoles = asyncHandler(async (req, res) => ok(res, await service.listRoles()));
|
||||
|
||||
export const postUser = asyncHandler(async (req, res) => {
|
||||
const user = await service.createUser(req.body, req.currentUser.id);
|
||||
ok(res, user, 201);
|
||||
});
|
||||
|
||||
export const putUser = asyncHandler(async (req, res) => {
|
||||
const user = await service.updateUser(Number(req.params.id), req.body, req.currentUser.id);
|
||||
ok(res, user);
|
||||
});
|
||||
|
||||
export const patchUserActive = asyncHandler(async (req, res) => {
|
||||
const user = await service.setUserActive(Number(req.params.id), !!req.body.isActive, req.currentUser.id);
|
||||
ok(res, user);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function findAll() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, r.name AS role_name,
|
||||
u.department_id, d.name AS department_name, u.position, u.is_active, u.created_at
|
||||
FROM users u
|
||||
JOIN roles r ON r.id = u.role_id
|
||||
LEFT JOIN departments d ON d.id = u.department_id
|
||||
ORDER BY u.name`
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function findById(id) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, u.department_id, u.position, u.is_active
|
||||
FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`,
|
||||
[id]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function findByEmail(email) {
|
||||
const [rows] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function create({ name, email, passwordHash, roleId, departmentId, position }) {
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO users (name, email, password_hash, role_id, department_id, position, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1)`,
|
||||
[name, email, passwordHash, roleId, departmentId || null, position || null]
|
||||
);
|
||||
return findById(result.insertId);
|
||||
}
|
||||
|
||||
export async function update(id, { name, roleId, departmentId, position }) {
|
||||
await pool.query(
|
||||
`UPDATE users SET name = ?, role_id = ?, department_id = ?, position = ? WHERE id = ?`,
|
||||
[name, roleId, departmentId || null, position || null, id]
|
||||
);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function setActive(id, isActive) {
|
||||
await pool.query('UPDATE users SET is_active = ? WHERE id = ?', [isActive ? 1 : 0, id]);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function listRoles() {
|
||||
const [rows] = await pool.query('SELECT id, code, name FROM roles ORDER BY id');
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth, requireRole, CAN_MANAGE_USERS } from '../../middleware/auth.js';
|
||||
import { getUsers, getRoles, postUser, putUser, patchUserActive } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.get('/', getUsers);
|
||||
router.get('/roles', getRoles);
|
||||
router.post('/', requireRole(...CAN_MANAGE_USERS), postUser);
|
||||
router.put('/:id', requireRole(...CAN_MANAGE_USERS), putUser);
|
||||
router.patch('/:id/active', requireRole(...CAN_MANAGE_USERS), patchUserActive);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,41 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import * as repo from './repository.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
|
||||
export const listUsers = () => repo.findAll();
|
||||
export const listRoles = () => repo.listRoles();
|
||||
|
||||
export async function createUser(payload, actorId) {
|
||||
if (!payload.name || !payload.email || !payload.password || !payload.roleId) {
|
||||
throw new HttpError(400, 'Nombre, email, clave y rol son obligatorios');
|
||||
}
|
||||
const existing = await repo.findByEmail(payload.email);
|
||||
if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email');
|
||||
|
||||
const passwordHash = await bcrypt.hash(payload.password, 12);
|
||||
const user = await repo.create({ ...payload, passwordHash });
|
||||
await logAudit({ userId: actorId, action: 'create', entityType: 'user', entityId: user.id });
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function updateUser(id, payload, actorId) {
|
||||
const existing = await repo.findById(id);
|
||||
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
|
||||
const user = await repo.update(id, payload);
|
||||
await logAudit({ userId: actorId, action: 'update', entityType: 'user', entityId: id });
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function setUserActive(id, isActive, actorId) {
|
||||
const existing = await repo.findById(id);
|
||||
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
|
||||
const user = await repo.setActive(id, isActive);
|
||||
await logAudit({
|
||||
userId: actorId,
|
||||
action: isActive ? 'activate' : 'deactivate',
|
||||
entityType: 'user',
|
||||
entityId: id,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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})`);
|
||||
});
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user