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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user