e65111a7f4
Permisos (superadmin, viewer, busqueda, tareas recurrentes), bootstrap atomico, revocacion de JWT por version de sesion (028), control optimista de edicion (029), logo sin SVG + CSP, ingesta endurecida, correos escapados, borradores, paginacion de proyectos, accesibilidad de dialogos/controles e i18n. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
9.8 KiB
JavaScript
199 lines
9.8 KiB
JavaScript
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, pr.level AS priority_level,
|
|
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.edit_version, p.created_by, p.created_at, p.updated_at, p.deleted_at, p.deleted_by,
|
|
(SELECT COUNT(*) FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id <> p.owner_user_id) AS shared_count,
|
|
(SELECT COUNT(*) FROM project_members pm2 WHERE pm2.project_id = p.id) AS members_count,
|
|
(SELECT COUNT(*) FROM activities act WHERE act.project_id = p.id AND act.deleted_at IS NULL) AS activities_total,
|
|
(SELECT COUNT(*) FROM activities act2 WHERE act2.project_id = p.id AND act2.deleted_at IS NULL AND act2.progress_pct >= 100) AS activities_complete,
|
|
(SELECT COUNT(*) FROM comments c WHERE c.entity_type = 'project' AND c.entity_id = p.id) AS comments_count,
|
|
(SELECT COUNT(*) FROM attachments att WHERE att.entity_type = 'project' AND att.entity_id = p.id) AS documents_count
|
|
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
|
|
`;
|
|
|
|
// Los COUNT(*) de subconsultas correlacionadas vuelven de mysql2 como string/BigInt-like --
|
|
// se castean a Number aqui para que el frontend los reciba como numero real (orden, comparar).
|
|
function castCounts(row) {
|
|
return {
|
|
...row,
|
|
shared_count: Number(row.shared_count),
|
|
members_count: Number(row.members_count),
|
|
activities_total: Number(row.activities_total),
|
|
activities_complete: Number(row.activities_complete),
|
|
comments_count: Number(row.comments_count),
|
|
documents_count: Number(row.documents_count),
|
|
};
|
|
}
|
|
|
|
export async function findAll({ statusId, priorityId, ownerUserId, memberUserId, trash, page = 1, pageSize = 20, dueSoon, progress, q } = {}) {
|
|
const clauses = [trash ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL'];
|
|
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); }
|
|
if (dueSoon) clauses.push('ps.is_closed = 0 AND p.estimated_end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY)');
|
|
if (progress === 'hide') clauses.push('p.progress_pct < 100');
|
|
if (progress === 'only') clauses.push('p.progress_pct >= 100');
|
|
if (q) { clauses.push('(p.code LIKE ? OR p.name LIKE ? OR u.name LIKE ?)'); params.push(...Array(3).fill(`%${q}%`)); }
|
|
// Restringe a proyectos donde el usuario es miembro (compartidos con el, o el propio dueño ya
|
|
// que quien crea un proyecto queda como miembro 'owner' automaticamente -- ver create()).
|
|
if (memberUserId) {
|
|
clauses.push('EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)');
|
|
params.push(memberUserId);
|
|
}
|
|
const where = `WHERE ${clauses.join(' AND ')}`;
|
|
const [[{ total }]] = await pool.query(`SELECT COUNT(*) AS total FROM projects p JOIN project_statuses ps ON ps.id = p.status_id JOIN users u ON u.id = p.owner_user_id ${where}`, params);
|
|
const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.updated_at DESC, p.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize]);
|
|
return { items: rows.map(castCounts), total: Number(total), page, pageSize };
|
|
}
|
|
|
|
export async function findOptions(memberUserId) {
|
|
const [rows] = await pool.query(
|
|
`SELECT p.id, p.code, p.name FROM projects p WHERE p.deleted_at IS NULL
|
|
${memberUserId ? 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)' : ''}
|
|
ORDER BY p.name`, memberUserId ? [memberUserId] : []
|
|
);
|
|
return rows;
|
|
}
|
|
|
|
export async function findById(id, { includeDeleted = false } = {}) {
|
|
const where = includeDeleted ? 'p.id = ?' : 'p.id = ? AND p.deleted_at IS NULL';
|
|
const [rows] = await pool.query(`${LIST_SELECT} WHERE ${where}`, [id]);
|
|
return rows[0] ? castCounts(rows[0]) : null;
|
|
}
|
|
|
|
export async function softDelete(id, userId) {
|
|
await pool.query('UPDATE projects SET deleted_at = NOW(), deleted_by = ?, edit_version = edit_version + 1 WHERE id = ? AND deleted_at IS NULL', [userId, id]);
|
|
}
|
|
|
|
export async function restore(id) {
|
|
await pool.query('UPDATE projects SET deleted_at = NULL, deleted_by = NULL, edit_version = edit_version + 1 WHERE id = ?', [id]);
|
|
}
|
|
|
|
export async function hardDelete(id) {
|
|
await pool.query('DELETE FROM projects WHERE id = ?', [id]);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Usado al crear un proyecto sin elegir estado explicito (ej. al convertir una actividad en
|
|
// proyecto): el primer estado no-cerrado por orden manual, tipicamente "Planificado".
|
|
export async function getDefaultStatusId() {
|
|
const [rows] = await pool.query('SELECT id FROM project_statuses WHERE is_closed = 0 ORDER BY sort_order LIMIT 1');
|
|
return rows[0]?.id || null;
|
|
}
|
|
|
|
export async function update(id, data) {
|
|
const [result] = 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 = ?, edit_version = edit_version + 1
|
|
WHERE id = ? AND edit_version = ? AND deleted_at IS NULL`,
|
|
[
|
|
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, data.editVersion,
|
|
]
|
|
);
|
|
return result.affectedRows ? findById(id) : null;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// null = no es miembro; 'member' = miembro sin rol especifico; 'admin'/'owner' = con ese rol.
|
|
export async function getMembership(projectId, userId) {
|
|
const [rows] = await pool.query('SELECT role_in_project FROM project_members WHERE project_id = ? AND user_id = ?', [
|
|
projectId, userId,
|
|
]);
|
|
if (!rows.length) return null;
|
|
return rows[0].role_in_project || 'member';
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Nunca toca una fila con role_in_project = 'owner' (protege al dueño de perder ese rol por aqui).
|
|
export async function updateMemberRole(projectId, userId, roleInProject) {
|
|
await pool.query(
|
|
`UPDATE project_members SET role_in_project = ?
|
|
WHERE project_id = ? AND user_id = ? AND (role_in_project IS NULL OR role_in_project <> 'owner')`,
|
|
[roleInProject, projectId, userId]
|
|
);
|
|
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 = ? AND deleted_at IS NULL', [
|
|
projectId,
|
|
]);
|
|
return rows[0].avg_pct === null ? 0 : Number(rows[0].avg_pct);
|
|
}
|