feat: AR-ProjectManagement initial commit — JavaScript (Node.js / React) Express + Prisma (backend) / React + Axios (frontend)

This commit is contained in:
2026-07-03 12:16:44 -04:00
commit 0c1a9a802c
100 changed files with 56764 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
FROM node:20-alpine
WORKDIR /app
# Instalar dependencias de sistema para Prisma
RUN apk add --no-cache openssl
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx prisma generate
EXPOSE ${PORT:-4000}
CMD ["npm", "run", "dev"]
+36
View File
@@ -0,0 +1,36 @@
{
"name": "ar-pm-backend",
"version": "1.0.0",
"description": "AR Project Management - Backend API",
"main": "src/index.js",
"scripts": {
"dev": "nodemon src/index.js",
"start": "node src/index.js",
"prisma:migrate": "npx prisma migrate dev",
"prisma:studio": "npx prisma studio",
"seed": "node prisma/seed.js",
"test": "jest --coverage"
},
"dependencies": {
"@prisma/client": "^5.14.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.19.2",
"express-rate-limit": "^7.3.1",
"express-validator": "^7.1.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"speakeasy": "^2.0.0",
"uuid": "^10.0.0",
"winston": "^3.13.0",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"nodemon": "^3.1.3",
"prisma": "^5.14.0",
"jest": "^29.7.0",
"supertest": "^7.0.0"
}
}
+215
View File
@@ -0,0 +1,215 @@
// AR-ProjectManagement — Prisma Schema
// Base de datos: PostgreSQL 16
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================================
// ENUMS
// ============================================================
enum Role {
SUPER_ADMIN
DIRECTOR
GERENTE
JEFE_AREA
COLABORADOR
FINANCIERO
AUDITOR
}
enum ProjectStatus {
ACTIVO
PAUSADO
COMPLETADO
EN_RIESGO
CANCELADO
}
enum TaskStatus {
PENDIENTE
EN_PROCESO
BLOQUEADA
EN_VERIFICACION
NO_CONFORME
APROBADO_TECNICO
ACEPTADO_FORMAL
COMPLETADA
}
enum Priority {
CRITICO
ALTO
MEDIO
BAJO
}
// ============================================================
// MODULO 1: USUARIOS Y AUTENTICACION
// ============================================================
model User {
id String @id @default(uuid())
email String @unique
passwordHash String @map("password_hash")
firstName String @map("first_name")
lastName String @map("last_name")
role Role @default(COLABORADOR)
isActive Boolean @default(true) @map("is_active")
isTwoFactorEnabled Boolean @default(false) @map("is_two_factor_enabled")
twoFactorSecret String? @map("two_factor_secret")
loginAttempts Int @default(0) @map("login_attempts")
lockedUntil DateTime? @map("locked_until")
lastLoginAt DateTime? @map("last_login_at")
lastLoginIp String? @map("last_login_ip")
avatarUrl String? @map("avatar_url")
phone String?
department String?
jobTitle String? @map("job_title")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
refreshTokens RefreshToken[]
auditLogs AuditLog[]
projectMembers ProjectMember[]
tasksAssigned Task[] @relation("TaskAssignee")
tasksCreated Task[] @relation("TaskCreator")
comments Comment[]
@@map("users")
}
model RefreshToken {
id String @id @default(uuid())
token String @unique
userId String @map("user_id")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
revokedAt DateTime? @map("revoked_at")
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("refresh_tokens")
}
// ============================================================
// LOG DE AUDITORIA INMUTABLE
// ============================================================
model AuditLog {
id String @id @default(uuid())
userId String? @map("user_id")
action String
entity String
entityId String? @map("entity_id")
oldValues Json? @map("old_values")
newValues Json? @map("new_values")
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@map("audit_logs")
}
// ============================================================
// MODULO 3: PROYECTOS
// ============================================================
model Project {
id String @id @default(uuid())
code String @unique
name String
description String?
type String
category String
client String?
startDate DateTime @map("start_date")
plannedEndDate DateTime @map("planned_end_date")
actualEndDate DateTime? @map("actual_end_date")
budget Decimal @db.Decimal(15, 2)
contingencyPct Decimal @default(10) @map("contingency_pct") @db.Decimal(5, 2)
currency String @default("USD")
priority Priority @default(MEDIO)
status ProjectStatus @default(ACTIVO)
currentPhase String? @map("current_phase")
methodology String?
tags String[]
ragStatus String @default("VERDE") @map("rag_status")
progressPct Decimal @default(0) @map("progress_pct") @db.Decimal(5, 2)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
members ProjectMember[]
tasks Task[]
@@map("projects")
}
model ProjectMember {
id String @id @default(uuid())
projectId String @map("project_id")
userId String @map("user_id")
role String
joinedAt DateTime @default(now()) @map("joined_at")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([projectId, userId])
@@map("project_members")
}
// ============================================================
// MODULO 4: TAREAS
// ============================================================
model Task {
id String @id @default(uuid())
projectId String @map("project_id")
parentId String? @map("parent_id")
wbsId String? @map("wbs_id")
title String
description String?
assigneeId String? @map("assignee_id")
createdById String @map("created_by_id")
startDate DateTime? @map("start_date")
dueDate DateTime? @map("due_date")
estimatedHours Decimal? @map("estimated_hours") @db.Decimal(8, 2)
actualHours Decimal? @map("actual_hours") @db.Decimal(8, 2)
progressPct Decimal @default(0) @map("progress_pct") @db.Decimal(5, 2)
status TaskStatus @default(PENDIENTE)
priority Priority @default(MEDIO)
estimatedCost Decimal? @map("estimated_cost") @db.Decimal(15, 2)
actualCost Decimal? @map("actual_cost") @db.Decimal(15, 2)
tags String[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id])
creator User @relation("TaskCreator", fields: [createdById], references: [id])
parent Task? @relation("TaskChildren", fields: [parentId], references: [id])
children Task[] @relation("TaskChildren")
comments Comment[]
@@map("tasks")
}
model Comment {
id String @id @default(uuid())
taskId String @map("task_id")
userId String @map("user_id")
content String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@map("comments")
}
+205
View File
@@ -0,0 +1,205 @@
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcryptjs');
const prisma = new PrismaClient();
async function main() {
// Users
const passwordHash = await bcrypt.hash('Admin123!', 12);
const superAdmin = await prisma.user.upsert({
where: { email: 'admin@arpm.com' },
update: {},
create: {
email: 'admin@arpm.com',
passwordHash,
firstName: 'Admin',
lastName: 'Sistema',
role: 'SUPER_ADMIN',
},
});
const director = await prisma.user.upsert({
where: { email: 'director@arpm.com' },
update: {},
create: {
email: 'director@arpm.com',
passwordHash,
firstName: 'Carlos',
lastName: 'Ramírez',
role: 'DIRECTOR',
department: 'Dirección',
jobTitle: 'Director General',
},
});
const gerente = await prisma.user.upsert({
where: { email: 'gerente@arpm.com' },
update: {},
create: {
email: 'gerente@arpm.com',
passwordHash,
firstName: 'Ana',
lastName: 'Torres',
role: 'GERENTE',
department: 'Proyectos',
jobTitle: 'Gerente de Proyectos',
},
});
const colaborador = await prisma.user.upsert({
where: { email: 'colaborador@arpm.com' },
update: {},
create: {
email: 'colaborador@arpm.com',
passwordHash,
firstName: 'Luis',
lastName: 'Mendoza',
role: 'COLABORADOR',
department: 'Ingeniería',
jobTitle: 'Desarrollador Senior',
},
});
// Projects
const proj1 = await prisma.project.upsert({
where: { code: 'PRY-2026-001' },
update: {},
create: {
code: 'PRY-2026-001',
name: 'Construcción Torre Corporativa Norte',
description: 'Proyecto de construcción de edificio de 20 pisos para uso corporativo',
type: 'Construcción',
category: 'Infraestructura',
client: 'Inmobiliaria Del Valle',
startDate: new Date('2026-01-15'),
plannedEndDate: new Date('2027-12-31'),
budget: 15000000.00,
priority: 'ALTO',
status: 'ACTIVO',
currentPhase: 'Cimentación',
methodology: 'PMI',
ragStatus: 'VERDE',
progressPct: 12.5,
tags: ['construcción', 'corporativo', 'norte'],
},
});
const proj2 = await prisma.project.upsert({
where: { code: 'PRY-2026-002' },
update: {},
create: {
code: 'PRY-2026-002',
name: 'Álbum Musical "Horizontes"',
description: 'Producción completa del cuarto álbum del artista Marco Solís',
type: 'Producción Musical',
category: 'Entretenimiento',
client: 'Sony Music Latin',
startDate: new Date('2026-03-01'),
plannedEndDate: new Date('2026-09-30'),
budget: 250000.00,
priority: 'MEDIO',
status: 'ACTIVO',
currentPhase: 'Grabación',
methodology: 'Agile',
ragStatus: 'AMARILLO',
progressPct: 35.0,
tags: ['música', 'álbum', 'producción'],
},
});
const proj3 = await prisma.project.upsert({
where: { code: 'PRY-2026-003' },
update: {},
create: {
code: 'PRY-2026-003',
name: 'Implementación ERP Empresarial',
description: 'Migración y modernización del sistema ERP corporativo',
type: 'Software',
category: 'Tecnología',
client: 'Grupo Empresarial XYZ',
startDate: new Date('2026-02-01'),
plannedEndDate: new Date('2026-11-30'),
budget: 800000.00,
priority: 'CRITICO',
status: 'EN_RIESGO',
currentPhase: 'Desarrollo',
methodology: 'Scrum',
ragStatus: 'ROJO',
progressPct: 48.0,
tags: ['erp', 'software', 'migración'],
},
});
// Project members
for (const proj of [proj1, proj2, proj3]) {
await prisma.projectMember.upsert({
where: { projectId_userId: { projectId: proj.id, userId: gerente.id } },
update: {},
create: { projectId: proj.id, userId: gerente.id, role: 'GERENTE' },
});
await prisma.projectMember.upsert({
where: { projectId_userId: { projectId: proj.id, userId: colaborador.id } },
update: {},
create: { projectId: proj.id, userId: colaborador.id, role: 'COLABORADOR' },
});
}
// Sample tasks for ERP project
const task1 = await prisma.task.create({
data: {
projectId: proj3.id,
title: 'Análisis de requerimientos',
description: 'Levantamiento y documentación de todos los requerimientos del negocio',
createdById: gerente.id,
assigneeId: colaborador.id,
status: 'COMPLETADA',
priority: 'CRITICO',
estimatedHours: 80,
actualHours: 92,
progressPct: 100,
startDate: new Date('2026-02-01'),
dueDate: new Date('2026-02-28'),
},
});
await prisma.task.create({
data: {
projectId: proj3.id,
parentId: task1.id,
title: 'Entrevistas con stakeholders',
createdById: gerente.id,
assigneeId: colaborador.id,
status: 'COMPLETADA',
priority: 'ALTO',
estimatedHours: 20,
progressPct: 100,
},
});
await prisma.task.create({
data: {
projectId: proj3.id,
title: 'Desarrollo módulo de inventarios',
description: 'Implementación del módulo de gestión de inventarios con integración WMS',
createdById: gerente.id,
assigneeId: colaborador.id,
status: 'EN_PROCESO',
priority: 'CRITICO',
estimatedHours: 200,
actualHours: 120,
progressPct: 55,
startDate: new Date('2026-03-15'),
dueDate: new Date('2026-06-30'),
},
});
console.log('Seed completado:');
console.log(' Usuarios: admin@arpm.com, director@arpm.com, gerente@arpm.com, colaborador@arpm.com');
console.log(' Password para todos: Admin123!');
console.log(' Proyectos: PRY-2026-001, PRY-2026-002, PRY-2026-003');
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(async () => { await prisma.$disconnect(); });
+154
View File
@@ -0,0 +1,154 @@
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const { validationResult } = require("express-validator");
const prisma = require("../models/prisma");
const logger = require("../utils/logger");
const generateTokens = (userId, role) => {
const accessToken = jwt.sign(
{ userId, role },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_ACCESS_EXPIRES || "15m" }
);
const refreshToken = jwt.sign(
{ userId },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: process.env.JWT_REFRESH_EXPIRES || "7d" }
);
return { accessToken, refreshToken };
};
const login = async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const { email, password } = req.body;
try {
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
if (!user || !user.isActive) {
return res.status(401).json({ error: "Credenciales invalidas" });
}
// Verificar si esta bloqueado
if (user.lockedUntil && user.lockedUntil > new Date()) {
const minutesLeft = Math.ceil((user.lockedUntil - new Date()) / 60000);
return res.status(423).json({ error: "Cuenta bloqueada. Intenta en " + minutesLeft + " minutos." });
}
const passwordValid = await bcrypt.compare(password, user.passwordHash);
if (!passwordValid) {
const maxAttempts = parseInt(process.env.MAX_LOGIN_ATTEMPTS) || 5;
const newAttempts = user.loginAttempts + 1;
const lockData = newAttempts >= maxAttempts
? { loginAttempts: newAttempts, lockedUntil: new Date(Date.now() + parseInt(process.env.LOCK_TIME_MINUTES || 15) * 60000) }
: { loginAttempts: newAttempts };
await prisma.user.update({ where: { id: user.id }, data: lockData });
return res.status(401).json({ error: "Credenciales invalidas" });
}
// Reset intentos fallidos
await prisma.user.update({
where: { id: user.id },
data: { loginAttempts: 0, lockedUntil: null, lastLoginAt: new Date(), lastLoginIp: req.ip },
});
const { accessToken, refreshToken } = generateTokens(user.id, user.role);
// Guardar refresh token en BD
await prisma.refreshToken.create({
data: {
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
ipAddress: req.ip,
userAgent: req.headers["user-agent"],
},
});
// Audit log
await prisma.auditLog.create({
data: { userId: user.id, action: "LOGIN", entity: "users", entityId: user.id, ipAddress: req.ip },
});
res.json({
accessToken,
refreshToken,
user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, role: user.role },
});
} catch (error) {
logger.error("Error en login:", error);
res.status(500).json({ error: "Error interno del servidor" });
}
};
const refresh = async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) return res.status(400).json({ error: "Refresh token requerido" });
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const storedToken = await prisma.refreshToken.findUnique({
where: { token: refreshToken },
include: { user: true },
});
if (!storedToken || storedToken.revokedAt || storedToken.expiresAt < new Date()) {
return res.status(401).json({ error: "Refresh token invalido o expirado" });
}
if (!storedToken.user.isActive) {
return res.status(401).json({ error: "Usuario inactivo" });
}
// Revocar token antiguo y emitir nuevos
await prisma.refreshToken.update({ where: { id: storedToken.id }, data: { revokedAt: new Date() } });
const tokens = generateTokens(storedToken.user.id, storedToken.user.role);
await prisma.refreshToken.create({
data: {
token: tokens.refreshToken,
userId: storedToken.user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
ipAddress: req.ip,
userAgent: req.headers["user-agent"],
},
});
res.json(tokens);
} catch (error) {
res.status(401).json({ error: "Refresh token invalido" });
}
};
const logout = async (req, res) => {
const { refreshToken } = req.body;
if (refreshToken) {
await prisma.refreshToken.updateMany({
where: { token: refreshToken, userId: req.user.id },
data: { revokedAt: new Date() },
}).catch(() => {});
}
await prisma.auditLog.create({
data: { userId: req.user.id, action: "LOGOUT", entity: "users", entityId: req.user.id, ipAddress: req.ip },
}).catch(() => {});
res.json({ message: "Sesion cerrada exitosamente" });
};
const me = async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: { id: true, email: true, firstName: true, lastName: true, role: true, isActive: true, phone: true, department: true, jobTitle: true, avatarUrl: true, lastLoginAt: true, createdAt: true },
});
res.json(user);
};
module.exports = { login, refresh, logout, me };
@@ -0,0 +1,57 @@
const prisma = require("../models/prisma");
const logger = require("../utils/logger");
const getGlobalKPIs = async (req, res) => {
try {
const [
totalProjects,
projectsByStatus,
recentProjects,
totalTasks,
tasksByStatus,
totalUsers,
] = await Promise.all([
prisma.project.count(),
prisma.project.groupBy({ by: ["status"], _count: true }),
prisma.project.findMany({
take: 5,
orderBy: { createdAt: "desc" },
select: { id: true, name: true, code: true, status: true, ragStatus: true, progressPct: true, budget: true, priority: true },
}),
prisma.task.count(),
prisma.task.groupBy({ by: ["status"], _count: true }),
prisma.user.count({ where: { isActive: true } }),
]);
const statusMap = {};
projectsByStatus.forEach((s) => { statusMap[s.status] = s._count; });
const taskStatusMap = {};
tasksByStatus.forEach((s) => { taskStatusMap[s.status] = s._count; });
res.json({
projects: {
total: totalProjects,
active: statusMap["ACTIVO"] || 0,
paused: statusMap["PAUSADO"] || 0,
completed: statusMap["COMPLETADO"] || 0,
atRisk: statusMap["EN_RIESGO"] || 0,
cancelled: statusMap["CANCELADO"] || 0,
},
tasks: {
total: totalTasks,
pending: taskStatusMap["PENDIENTE"] || 0,
inProgress: taskStatusMap["EN_PROCESO"] || 0,
blocked: taskStatusMap["BLOQUEADA"] || 0,
completed: taskStatusMap["COMPLETADA"] || 0,
},
users: { active: totalUsers },
recentProjects,
});
} catch (error) {
logger.error("Error obteniendo KPIs globales:", error);
res.status(500).json({ error: "Error obteniendo KPIs" });
}
};
module.exports = { getGlobalKPIs };
@@ -0,0 +1,114 @@
const { validationResult } = require("express-validator");
const prisma = require("../models/prisma");
const logger = require("../utils/logger");
const generateProjectCode = async () => {
const year = new Date().getFullYear();
const count = await prisma.project.count();
return "PRY-" + year + "-" + String(count + 1).padStart(3, "0");
};
const getAll = async (req, res) => {
try {
const { status, category, search, page = 1, limit = 20 } = req.query;
const where = {};
if (status) where.status = status;
if (category) where.category = category;
if (search) where.OR = [{ name: { contains: search, mode: "insensitive" } }, { code: { contains: search, mode: "insensitive" } }];
// COLABORADOR solo ve proyectos donde es miembro
if (req.user.role === "COLABORADOR" || req.user.role === "JEFE_AREA") {
where.members = { some: { userId: req.user.id } };
}
const [projects, total] = await Promise.all([
prisma.project.findMany({
where,
skip: (parseInt(page) - 1) * parseInt(limit),
take: parseInt(limit),
orderBy: { createdAt: "desc" },
include: {
members: { include: { user: { select: { id: true, firstName: true, lastName: true, avatarUrl: true } } } },
_count: { select: { tasks: true } },
},
}),
prisma.project.count({ where }),
]);
res.json({ data: projects, total, page: parseInt(page), limit: parseInt(limit), totalPages: Math.ceil(total / parseInt(limit)) });
} catch (error) {
logger.error("Error obteniendo proyectos:", error);
res.status(500).json({ error: "Error obteniendo proyectos" });
}
};
const getById = async (req, res) => {
try {
const project = await prisma.project.findUnique({
where: { id: req.params.id },
include: {
members: { include: { user: { select: { id: true, firstName: true, lastName: true, role: true, avatarUrl: true } } } },
tasks: { where: { parentId: null }, include: { children: true, assignee: { select: { id: true, firstName: true, lastName: true } } } },
},
});
if (!project) return res.status(404).json({ error: "Proyecto no encontrado" });
res.json(project);
} catch (error) {
res.status(500).json({ error: "Error obteniendo proyecto" });
}
};
const ALLOWED_PROJECT_CREATE_FIELDS = [
'name', 'description', 'type', 'category', 'client',
'startDate', 'plannedEndDate', 'budget', 'priority',
'status', 'currentPhase', 'methodology', 'tags',
];
const ALLOWED_PROJECT_UPDATE_FIELDS = [
'name', 'description', 'type', 'category', 'client',
'startDate', 'plannedEndDate', 'budget', 'priority',
'status', 'currentPhase', 'methodology', 'ragStatus',
'progressPct', 'tags',
];
const pickFields = (obj, allowed) =>
Object.fromEntries(Object.entries(obj).filter(([k]) => allowed.includes(k)));
const create = async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
try {
const code = await generateProjectCode();
const safeData = pickFields(req.body, ALLOWED_PROJECT_CREATE_FIELDS);
const project = await prisma.project.create({
data: { ...safeData, code, createdBy: req.user.id },
});
// Agregar al creador como miembro GERENTE por defecto
await prisma.projectMember.create({
data: { projectId: project.id, userId: req.user.id, role: "GERENTE" },
});
res.status(201).json(project);
} catch (error) {
logger.error("Error creando proyecto:", error);
res.status(500).json({ error: "Error creando proyecto" });
}
};
const update = async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
try {
const safeData = pickFields(req.body, ALLOWED_PROJECT_UPDATE_FIELDS);
const project = await prisma.project.update({ where: { id: req.params.id }, data: safeData });
res.json(project);
} catch (error) {
if (error.code === "P2025") return res.status(404).json({ error: "Proyecto no encontrado" });
res.status(500).json({ error: "Error actualizando proyecto" });
}
};
module.exports = { getAll, getById, create, update };
+124
View File
@@ -0,0 +1,124 @@
const prisma = require('../models/prisma');
const { validationResult } = require('express-validator');
const { ROLES } = require('../utils/roles');
exports.getByProject = async (req, res, next) => {
try {
const { projectId } = req.params;
const { status, assigneeId, parentId } = req.query;
const where = {
projectId,
parentId: parentId === 'null' ? null : parentId || undefined,
};
if (status) where.status = status;
if (assigneeId) where.assigneeId = assigneeId;
const tasks = await prisma.task.findMany({
where,
include: {
assignee: { select: { id: true, firstName: true, lastName: true, avatarUrl: true } },
creator: { select: { id: true, firstName: true, lastName: true } },
_count: { select: { children: true, comments: true } },
},
orderBy: [{ priority: 'asc' }, { dueDate: 'asc' }],
});
res.json({ tasks });
} catch (err) {
next(err);
}
};
exports.getById = async (req, res, next) => {
try {
const task = await prisma.task.findUniqueOrThrow({
where: { id: req.params.id },
include: {
assignee: { select: { id: true, firstName: true, lastName: true, avatarUrl: true } },
creator: { select: { id: true, firstName: true, lastName: true } },
children: {
include: {
assignee: { select: { id: true, firstName: true, lastName: true } },
},
},
comments: {
include: { user: { select: { id: true, firstName: true, lastName: true } } },
orderBy: { createdAt: 'asc' },
},
},
});
res.json({ task });
} catch (err) {
next(err);
}
};
exports.create = async (req, res, next) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const task = await prisma.task.create({
data: { ...req.body, createdById: req.user.id },
});
res.status(201).json({ task });
} catch (err) {
next(err);
}
};
const ALLOWED_TASK_UPDATE_FIELDS = [
'title', 'description', 'status', 'priority', 'assigneeId',
'startDate', 'dueDate', 'estimatedHours', 'actualHours',
'progressPct', 'parentId', 'tags',
];
const pickTaskFields = (obj) =>
Object.fromEntries(Object.entries(obj).filter(([k]) => ALLOWED_TASK_UPDATE_FIELDS.includes(k)));
exports.update = async (req, res, next) => {
try {
const safeData = pickTaskFields(req.body);
const task = await prisma.task.update({
where: { id: req.params.id },
data: safeData,
});
res.json({ task });
} catch (err) {
next(err);
}
};
exports.updateStatus = async (req, res, next) => {
try {
const { status } = req.body;
if (!status) return res.status(400).json({ message: 'status requerido' });
const task = await prisma.task.update({
where: { id: req.params.id },
data: { status },
include: {
assignee: { select: { id: true, firstName: true, lastName: true } },
},
});
res.json({ task });
} catch (err) {
next(err);
}
};
exports.addComment = async (req, res, next) => {
try {
const { content } = req.body;
if (!content?.trim()) return res.status(400).json({ message: 'Contenido requerido' });
const comment = await prisma.comment.create({
data: { taskId: req.params.id, userId: req.user.id, content: content.trim() },
include: { user: { select: { id: true, firstName: true, lastName: true } } },
});
res.status(201).json({ comment });
} catch (err) {
next(err);
}
};
@@ -0,0 +1,52 @@
const prisma = require('../models/prisma');
const bcrypt = require('bcryptjs');
const { validationResult } = require('express-validator');
exports.getAll = async (req, res, next) => {
try {
const users = await prisma.user.findMany({
where: { isActive: true },
select: { id: true, email: true, firstName: true, lastName: true, role: true, department: true, jobTitle: true, avatarUrl: true, lastLoginAt: true },
orderBy: { firstName: 'asc' },
});
res.json({ users });
} catch (err) { next(err); }
};
exports.getById = async (req, res, next) => {
try {
const user = await prisma.user.findUniqueOrThrow({
where: { id: req.params.id },
select: { id: true, email: true, firstName: true, lastName: true, role: true, department: true, jobTitle: true, phone: true, avatarUrl: true, isActive: true, lastLoginAt: true, createdAt: true },
});
res.json({ user });
} catch (err) { next(err); }
};
exports.create = async (req, res, next) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const { password, ...rest } = req.body;
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { ...rest, passwordHash },
select: { id: true, email: true, firstName: true, lastName: true, role: true },
});
res.status(201).json({ user });
} catch (err) { next(err); }
};
exports.update = async (req, res, next) => {
try {
const { password, ...rest } = req.body;
const data = { ...rest };
if (password) data.passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.update({
where: { id: req.params.id },
data,
select: { id: true, email: true, firstName: true, lastName: true, role: true, department: true, jobTitle: true },
});
res.json({ user });
} catch (err) { next(err); }
};
+58
View File
@@ -0,0 +1,58 @@
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const { apiLimiter } = require('./middleware/rateLimiter.middleware');
const { errorHandler, notFound } = require('./middleware/error.middleware');
const logger = require('./utils/logger');
const authRoutes = require('./routes/auth.routes');
const dashboardRoutes = require('./routes/dashboard.routes');
const projectsRoutes = require('./routes/projects.routes');
const tasksRoutes = require('./routes/tasks.routes');
const usersRoutes = require('./routes/users.routes');
const app = express();
const PORT = process.env.PORT || 4000;
// Security
app.use(helmet());
app.use(cors({
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
credentials: true,
}));
// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Logging
app.use(morgan('combined', {
stream: { write: (msg) => logger.info(msg.trim()) },
}));
// Rate limiting
app.use('/api', apiLimiter);
// Health check (no auth)
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/projects', projectsRoutes);
app.use('/api/projects/:projectId/tasks', tasksRoutes);
app.use('/api/tasks', tasksRoutes);
app.use('/api/users', usersRoutes);
// Error handling
app.use(notFound);
app.use(errorHandler);
app.listen(PORT, () => {
logger.info(`AR-ProjectManagement backend running on port ${PORT}`);
});
module.exports = app;
@@ -0,0 +1,31 @@
const prisma = require("../models/prisma");
const logger = require("../utils/logger");
const auditLog = (action, entity) => {
return async (req, res, next) => {
const originalJson = res.json.bind(res);
res.json = async (data) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
await prisma.auditLog.create({
data: {
userId: req.user?.id || null,
action,
entity,
entityId: req.params?.id || data?.id || null,
newValues: data || null,
ipAddress: req.ip,
userAgent: req.headers["user-agent"],
},
});
} catch (err) {
logger.error("Error guardando audit log:", err);
}
}
return originalJson(data);
};
next();
};
};
module.exports = { auditLog };
+59
View File
@@ -0,0 +1,59 @@
const jwt = require("jsonwebtoken");
const prisma = require("../models/prisma");
const logger = require("../utils/logger");
const authenticate = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "Token de acceso requerido" });
}
const token = authHeader.substring(7);
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, email: true, firstName: true, lastName: true, role: true, isActive: true },
});
if (!user || !user.isActive) {
return res.status(401).json({ error: "Usuario no encontrado o inactivo" });
}
req.user = user;
next();
} catch (error) {
if (error.name === "TokenExpiredError") {
return res.status(401).json({ error: "Token expirado", code: "TOKEN_EXPIRED" });
}
if (error.name === "JsonWebTokenError") {
return res.status(401).json({ error: "Token invalido" });
}
logger.error("Error en middleware de autenticacion:", error);
res.status(500).json({ error: "Error interno de autenticacion" });
}
};
const authorize = (...allowedRoles) => {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: "No autenticado" });
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
error: "No tienes permisos para realizar esta accion",
requiredRoles: allowedRoles,
userRole: req.user.role,
});
}
next();
};
};
const readOnlyForAuditor = (req, res, next) => {
if (req.user?.role === "AUDITOR" && req.method !== "GET") {
return res.status(403).json({ error: "Los auditores solo tienen acceso de lectura" });
}
next();
};
module.exports = { authenticate, authorize, readOnlyForAuditor };
@@ -0,0 +1,17 @@
const logger = require("../utils/logger");
const errorHandler = (err, req, res, next) => {
logger.error({ message: err.message, url: req.url, method: req.method, userId: req.user?.id });
if (err.code === "P2002") return res.status(409).json({ error: "Valor duplicado", field: err.meta?.target });
if (err.code === "P2025") return res.status(404).json({ error: "Registro no encontrado" });
const status = err.status || err.statusCode || 500;
res.status(status).json({ error: status < 500 ? err.message : "Error interno del servidor" });
};
const notFound = (_req, res) => {
res.status(404).json({ error: "Ruta no encontrada" });
};
module.exports = { errorHandler, notFound };
@@ -0,0 +1,19 @@
const rateLimit = require("express-rate-limit");
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: parseInt(process.env.MAX_LOGIN_ATTEMPTS) || 5,
message: { error: "Demasiados intentos de login. Intenta en 15 minutos." },
standardHeaders: true,
legacyHeaders: false,
});
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
message: { error: "Demasiadas solicitudes." },
standardHeaders: true,
legacyHeaders: false,
});
module.exports = { loginLimiter, apiLimiter };
+22
View File
@@ -0,0 +1,22 @@
const { PrismaClient } = require("@prisma/client");
const logger = require("../utils/logger");
const prisma = new PrismaClient({
log: [
{ emit: "event", level: "query" },
{ emit: "event", level: "error" },
{ emit: "event", level: "warn" },
],
});
if (process.env.NODE_ENV === "development") {
prisma.$on("query", (e) => {
logger.debug(`Query: ${e.query} | Duration: ${e.duration}ms`);
});
}
prisma.$on("error", (e) => {
logger.error(`Prisma error: ${e.message}`);
});
module.exports = prisma;
+18
View File
@@ -0,0 +1,18 @@
const express = require('express');
const router = express.Router();
const { body } = require('express-validator');
const authController = require('../controllers/auth.controller');
const { authenticate } = require('../middleware/auth.middleware');
const { loginLimiter } = require('../middleware/rateLimiter.middleware');
const loginValidation = [
body('email').isEmail().normalizeEmail(),
body('password').notEmpty().isLength({ min: 6 }),
];
router.post('/login', loginLimiter, loginValidation, authController.login);
router.post('/refresh', authController.refresh);
router.post('/logout', authenticate, authController.logout);
router.get('/me', authenticate, authController.me);
module.exports = router;
+8
View File
@@ -0,0 +1,8 @@
const express = require('express');
const router = express.Router();
const dashboardController = require('../controllers/dashboard.controller');
const { authenticate } = require('../middleware/auth.middleware');
router.get('/kpis', authenticate, dashboardController.getGlobalKPIs);
module.exports = router;
+35
View File
@@ -0,0 +1,35 @@
const express = require('express');
const router = express.Router();
const { body } = require('express-validator');
const projectsController = require('../controllers/projects.controller');
const { authenticate, authorize, readOnlyForAuditor } = require('../middleware/auth.middleware');
const { ROLES } = require('../utils/roles');
const projectValidation = [
body('name').trim().notEmpty().isLength({ max: 200 }),
body('type').trim().notEmpty(),
body('category').trim().notEmpty(),
body('startDate').isISO8601(),
body('plannedEndDate').isISO8601(),
body('budget').isDecimal({ decimal_digits: '0,2' }),
];
router.use(authenticate);
router.get('/', projectsController.getAll);
router.get('/:id', projectsController.getById);
router.post(
'/',
authorize(ROLES.SUPER_ADMIN, ROLES.DIRECTOR, ROLES.GERENTE),
readOnlyForAuditor,
projectValidation,
projectsController.create
);
router.patch(
'/:id',
authorize(ROLES.SUPER_ADMIN, ROLES.DIRECTOR, ROLES.GERENTE),
readOnlyForAuditor,
projectsController.update
);
module.exports = router;
+13
View File
@@ -0,0 +1,13 @@
const express = require('express');
const router = express.Router({ mergeParams: true });
const tasksController = require('../controllers/tasks.controller');
const { authenticate, readOnlyForAuditor } = require('../middleware/auth.middleware');
router.use(authenticate);
router.get('/', tasksController.getByProject);
router.get('/:id', tasksController.getById);
router.post('/', readOnlyForAuditor, tasksController.create);
router.patch('/:id', readOnlyForAuditor, tasksController.update);
router.patch('/:id/status', readOnlyForAuditor, tasksController.updateStatus);
router.post('/:id/comments', tasksController.addComment);
module.exports = router;
+19
View File
@@ -0,0 +1,19 @@
const express = require('express');
const router = express.Router();
const { body } = require('express-validator');
const usersController = require('../controllers/users.controller');
const { authenticate, authorize } = require('../middleware/auth.middleware');
const { ROLES } = require('../utils/roles');
router.use(authenticate);
router.get('/', usersController.getAll);
router.get('/:id', usersController.getById);
router.post('/', authorize(ROLES.SUPER_ADMIN, ROLES.DIRECTOR), [
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }),
body('firstName').trim().notEmpty(),
body('lastName').trim().notEmpty(),
body('role').isIn(Object.values(ROLES)),
], usersController.create);
router.patch('/:id', authorize(ROLES.SUPER_ADMIN, ROLES.DIRECTOR), usersController.update);
module.exports = router;
+30
View File
@@ -0,0 +1,30 @@
const winston = require("winston");
const path = require("path");
const logFormat = winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
winston.format.errors({ stack: true }),
winston.format.json()
);
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
format: logFormat,
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}),
new winston.transports.File({
filename: path.join(__dirname, "../../logs/error.log"),
level: "error",
}),
new winston.transports.File({
filename: path.join(__dirname, "../../logs/combined.log"),
}),
],
});
module.exports = logger;
+18
View File
@@ -0,0 +1,18 @@
const ROLES = {
SUPER_ADMIN: "SUPER_ADMIN",
DIRECTOR: "DIRECTOR",
GERENTE: "GERENTE",
JEFE_AREA: "JEFE_AREA",
COLABORADOR: "COLABORADOR",
FINANCIERO: "FINANCIERO",
AUDITOR: "AUDITOR",
};
const ROLE_GROUPS = {
MANAGERS: ["SUPER_ADMIN", "DIRECTOR", "GERENTE"],
FINANCE: ["SUPER_ADMIN", "DIRECTOR", "FINANCIERO"],
APPROVERS: ["SUPER_ADMIN", "DIRECTOR"],
ALL_STAFF: Object.values(ROLES),
};
module.exports = { ROLES, ROLE_GROUPS };