chore: estructura inicial del MVP

Backend (Express + MariaDB) y frontend (React + Vite, identidad Fluent/Windows) con el
nucleo funcional: seguridad, organizacion, proyectos, actividades, subtareas, dependencias,
dashboard, calendario, gantt, comentarios, adjuntos, notificaciones in-app y catalogos.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-03 08:37:58 -05:00
commit 1d22adc42e
135 changed files with 10913 additions and 0 deletions
@@ -0,0 +1,29 @@
import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
export const getProjects = asyncHandler(async (req, res) => {
const { statusId, priorityId, ownerUserId } = req.query;
ok(res, await service.listProjects({ statusId, priorityId, ownerUserId }));
});
export const getProject = asyncHandler(async (req, res) => {
ok(res, await service.getProject(Number(req.params.id)));
});
export const postProject = asyncHandler(async (req, res) => {
ok(res, await service.createProject(req.body, req.currentUser), 201);
});
export const putProject = asyncHandler(async (req, res) => {
ok(res, await service.updateProject(Number(req.params.id), req.body, req.currentUser));
});
export const postMember = asyncHandler(async (req, res) => {
const members = await service.addMember(Number(req.params.id), Number(req.body.userId), req.body.roleInProject, req.currentUser);
ok(res, members, 201);
});
export const deleteMember = asyncHandler(async (req, res) => {
const members = await service.removeMember(Number(req.params.id), Number(req.params.userId), req.currentUser);
ok(res, members);
});
+119
View File
@@ -0,0 +1,119 @@
import { pool } from '../../config/db.js';
const LIST_SELECT = `
SELECT p.id, p.code, p.name, p.description, p.objective, p.project_type_id, pt.name AS project_type_name,
p.priority_id, pr.name AS priority_name, pr.color AS priority_color,
p.status_id, ps.name AS status_name, ps.color AS status_color, ps.is_closed AS status_is_closed,
p.owner_user_id, u.name AS owner_name,
p.requesting_department_id, rd.name AS requesting_department_name,
p.responsible_department_id, sd.name AS responsible_department_name,
p.start_date, p.estimated_end_date, p.actual_end_date, p.progress_pct,
p.risk_level, p.notes, p.created_by, p.created_at, p.updated_at
FROM projects p
JOIN priorities pr ON pr.id = p.priority_id
JOIN project_statuses ps ON ps.id = p.status_id
JOIN users u ON u.id = p.owner_user_id
LEFT JOIN project_types pt ON pt.id = p.project_type_id
LEFT JOIN departments rd ON rd.id = p.requesting_department_id
LEFT JOIN departments sd ON sd.id = p.responsible_department_id
`;
export async function findAll({ statusId, priorityId, ownerUserId } = {}) {
const clauses = [];
const params = [];
if (statusId) { clauses.push('p.status_id = ?'); params.push(statusId); }
if (priorityId) { clauses.push('p.priority_id = ?'); params.push(priorityId); }
if (ownerUserId) { clauses.push('p.owner_user_id = ?'); params.push(ownerUserId); }
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.created_at DESC`, params);
return rows;
}
export async function findById(id) {
const [rows] = await pool.query(`${LIST_SELECT} WHERE p.id = ?`, [id]);
return rows[0] || null;
}
export async function create(data) {
const [result] = await pool.query(
`INSERT INTO projects
(code, name, description, objective, project_type_id, priority_id, status_id, owner_user_id,
requesting_department_id, responsible_department_id, start_date, estimated_end_date,
risk_level, notes, created_by)
VALUES ('PENDING', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
data.name, data.description || null, data.objective || null, data.projectTypeId || null,
data.priorityId, data.statusId, data.ownerUserId,
data.requestingDepartmentId || null, data.responsibleDepartmentId || null,
data.startDate || null, data.estimatedEndDate || null,
data.riskLevel || 'low', data.notes || null, data.createdBy,
]
);
const id = result.insertId;
const code = `PRY-${String(id).padStart(4, '0')}`;
await pool.query('UPDATE projects SET code = ? WHERE id = ?', [code, id]);
// El creador queda como miembro por defecto.
await pool.query('INSERT IGNORE INTO project_members (project_id, user_id, role_in_project) VALUES (?, ?, ?)', [
id, data.ownerUserId, 'owner',
]);
return findById(id);
}
export async function update(id, data) {
await pool.query(
`UPDATE projects SET
name = ?, description = ?, objective = ?, project_type_id = ?, priority_id = ?, status_id = ?,
owner_user_id = ?, requesting_department_id = ?, responsible_department_id = ?,
start_date = ?, estimated_end_date = ?, actual_end_date = ?, risk_level = ?, notes = ?
WHERE id = ?`,
[
data.name, data.description || null, data.objective || null, data.projectTypeId || null,
data.priorityId, data.statusId, data.ownerUserId,
data.requestingDepartmentId || null, data.responsibleDepartmentId || null,
data.startDate || null, data.estimatedEndDate || null, data.actualEndDate || null,
data.riskLevel || 'low', data.notes || null, id,
]
);
return findById(id);
}
export async function updateProgress(id, progressPct) {
await pool.query('UPDATE projects SET progress_pct = ? WHERE id = ?', [progressPct, id]);
}
export async function listMembers(projectId) {
const [rows] = await pool.query(
`SELECT pm.user_id, u.name, u.email, pm.role_in_project, pm.added_at
FROM project_members pm JOIN users u ON u.id = pm.user_id
WHERE pm.project_id = ? ORDER BY u.name`,
[projectId]
);
return rows;
}
export async function isMember(projectId, userId) {
const [rows] = await pool.query('SELECT 1 FROM project_members WHERE project_id = ? AND user_id = ?', [
projectId, userId,
]);
return rows.length > 0;
}
export async function addMember(projectId, userId, roleInProject) {
await pool.query(
'INSERT IGNORE INTO project_members (project_id, user_id, role_in_project) VALUES (?, ?, ?)',
[projectId, userId, roleInProject || null]
);
return listMembers(projectId);
}
export async function removeMember(projectId, userId) {
await pool.query('DELETE FROM project_members WHERE project_id = ? AND user_id = ?', [projectId, userId]);
return listMembers(projectId);
}
export async function averageActivityProgress(projectId) {
const [rows] = await pool.query('SELECT AVG(progress_pct) AS avg_pct FROM activities WHERE project_id = ?', [
projectId,
]);
return rows[0].avg_pct === null ? 0 : Number(rows[0].avg_pct);
}
+20
View File
@@ -0,0 +1,20 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { getProjects, getProject, postProject, putProject, postMember, deleteMember } from './controller.js';
import { getGantt } from '../dashboard/controller.js';
import { getActivitiesByProject, postActivity } from '../activities/controller.js';
const router = Router();
router.use(requireAuth);
router.get('/', getProjects);
router.post('/', postProject);
router.get('/:id', getProject);
router.put('/:id', putProject);
router.get('/:id/gantt', getGantt);
router.post('/:id/members', postMember);
router.delete('/:id/members/:userId', deleteMember);
router.get('/:projectId/activities', getActivitiesByProject);
router.post('/:projectId/activities', postActivity);
export default router;
+73
View File
@@ -0,0 +1,73 @@
import * as repo from './repository.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { ROLES } from '../../middleware/auth.js';
// Alcance de escritura: admin/it_manager siempre; project_lead solo si es dueño o miembro.
export async function canWriteProject(user, project) {
if (user.roleCode === ROLES.ADMIN || user.roleCode === ROLES.IT_MANAGER) return true;
if (user.roleCode !== ROLES.PROJECT_LEAD) return false;
if (project.owner_user_id === user.id) return true;
return repo.isMember(project.id, user.id);
}
export const listProjects = (filters) => repo.findAll(filters);
export async function getProject(id) {
const project = await repo.findById(id);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
const members = await repo.listMembers(id);
return { ...project, members };
}
export async function createProject(payload, currentUser) {
if (!payload.name || !payload.priorityId || !payload.statusId) {
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
}
if (![ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD].includes(currentUser.roleCode)) {
throw new HttpError(403, 'No tienes permisos para crear proyectos');
}
const project = await repo.create({
...payload,
ownerUserId: payload.ownerUserId || currentUser.id,
createdBy: currentUser.id,
});
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'project', entityId: project.id });
return project;
}
export async function updateProject(id, payload, currentUser) {
const project = await repo.findById(id);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
if (!payload.name || !payload.priorityId || !payload.statusId) {
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
}
const updated = await repo.update(id, { ...payload, ownerUserId: payload.ownerUserId || project.owner_user_id });
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'project', entityId: id });
return updated;
}
// Recalcula el avance del proyecto como promedio del avance de sus actividades.
export async function recalcProjectProgress(projectId) {
const avg = await repo.averageActivityProgress(projectId);
await repo.updateProgress(projectId, Math.round(avg * 100) / 100);
}
export async function addMember(projectId, userId, roleInProject, currentUser) {
const project = await repo.findById(projectId);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
const members = await repo.addMember(projectId, userId, roleInProject);
await logAudit({ userId: currentUser.id, action: 'add_member', entityType: 'project', entityId: projectId, detail: { userId } });
return members;
}
export async function removeMember(projectId, userId, currentUser) {
const project = await repo.findById(projectId);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
const members = await repo.removeMember(projectId, userId);
await logAudit({ userId: currentUser.id, action: 'remove_member', entityType: 'project', entityId: projectId, detail: { userId } });
return members;
}