feat: AR-ProjectManagement initial commit — JavaScript (Node.js / React) Express + Prisma (backend) / React + Axios (frontend)
This commit is contained in:
@@ -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 };
|
||||
@@ -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); }
|
||||
};
|
||||
Reference in New Issue
Block a user