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