feat: version 3.0.0 -- Correcciones de auditoria de seguridad, accesibilidad y redundancia

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>
This commit is contained in:
Milton Gonzalez
2026-10-23 11:36:02 -05:00
parent 8ed1d63505
commit e65111a7f4
112 changed files with 2285 additions and 651 deletions
@@ -0,0 +1,2 @@
-- Una version por cuenta invalida todos los JWT anteriores al cambiar la clave.
ALTER TABLE users ADD COLUMN IF NOT EXISTS session_version INT UNSIGNED NOT NULL DEFAULT 1;
@@ -0,0 +1,14 @@
ALTER TABLE projects ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
ALTER TABLE comments ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
ALTER TABLE activities ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
ALTER TABLE departments ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
ALTER TABLE users ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
ALTER TABLE project_types ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
ALTER TABLE priorities ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
ALTER TABLE project_statuses ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
ALTER TABLE activity_statuses ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
ALTER TABLE notes ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
ALTER TABLE recurring_tasks ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
INSERT INTO system_settings (`key`, `value`) VALUES ('company_edit_version', '1'), ('smtp_edit_version', '1') ON DUPLICATE KEY UPDATE `value` = `value`;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "project-linkedcorp-backend",
"version": "2.14.0",
"version": "3.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "project-linkedcorp-backend",
"version": "2.14.0",
"version": "3.0.0",
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "2.19.0",
"version": "3.0.0",
"private": true,
"type": "module",
"main": "src/server.js",
+5 -1
View File
@@ -34,7 +34,11 @@ export const app = express();
// de uno por usuario) y ademas tira un ValidationError que ensucia los logs en cada request.
app.set('trust proxy', 1);
app.use(helmet({ contentSecurityPolicy: false }));
app.use(helmet({ contentSecurityPolicy: { directives: {
defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'], imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'"], objectSrc: ["'none'"], baseUri: ["'self'"], frameAncestors: ["'none'"],
} } }));
app.use(cors({ origin: env.corsOrigin, credentials: true, exposedHeaders: ['X-Vault-Token'] }));
// Ingesta del agente GLPI: ANTES del parser JSON (usa cuerpo crudo, sin CORS: la llama el agente).
app.use(inventoryIngestRoutes);
+4 -2
View File
@@ -17,13 +17,13 @@ export async function requireAuth(req, res, next) {
// Revalidar en cada request que la cuenta siga activa (un JWT firmado no basta).
const [rows] = await pool.query(
`SELECT u.id, u.name, u.email, u.is_active, u.department_id, r.code AS role_code
`SELECT u.id, u.name, u.email, u.is_active, u.session_version, u.department_id, r.code AS role_code
FROM users u JOIN roles r ON r.id = u.role_id
WHERE u.id = ? LIMIT 1`,
[payload.sub]
);
const user = rows[0];
if (!user || !user.is_active) return fail(res, 401, 'Cuenta inactiva o inexistente');
if (!user || !user.is_active || payload.sv !== user.session_version) return fail(res, 401, 'Sesion invalida o revocada');
req.currentUser = {
id: user.id,
@@ -59,3 +59,5 @@ export const CAN_MANAGE_USERS = [ROLES.SUPERADMIN, ROLES.ADMIN];
export const CAN_MANAGE_SMTP = [ROLES.SUPERADMIN];
export const CAN_MANAGE_SETTINGS = [ROLES.SUPERADMIN];
export const CAN_WRITE = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD, ROLES.COLLABORATOR];
export const CAN_EDIT_DEVICES = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER];
export const CAN_MANAGE_DEVICES = [ROLES.SUPERADMIN, ROLES.ADMIN];
+2 -2
View File
@@ -4,7 +4,7 @@ import crypto from 'node:crypto';
import multer from 'multer';
import { env } from '../config/env.js';
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']);
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp']);
export const logoDir = path.join(env.storageRoot, 'branding');
fs.mkdirSync(logoDir, { recursive: true });
@@ -24,7 +24,7 @@ export const uploadLogo = multer({
limits: { fileSize: 3 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (!ALLOWED_MIME.has(file.mimetype)) {
return cb(new Error('Tipo de archivo no permitido (usa PNG, JPG, WEBP o SVG)'));
return cb(new Error('Tipo de archivo no permitido (usa PNG, JPG o WEBP)'));
}
cb(null, true);
},
+9 -9
View File
@@ -5,7 +5,7 @@ const LIST_SELECT = `
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.sort_order, a.created_by, a.created_at, a.updated_at,
a.estimated_hours, a.edit_version, a.sort_order, a.created_by, a.created_at, a.updated_at,
a.deleted_at, a.deleted_by,
(SELECT COUNT(*) FROM activity_dependencies d WHERE d.activity_id = a.id) AS dependencies_count,
(SELECT COUNT(*) FROM comments c WHERE c.entity_type = 'activity' AND c.entity_id = a.id) AS comments_count,
@@ -62,18 +62,18 @@ export async function create(projectId, data) {
}
export async function update(id, data) {
await pool.query(
const [result] = 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 = ?`,
start_date = ?, due_date = ?, actual_end_date = ?, is_milestone = ?, estimated_hours = ?, edit_version = edit_version + 1
WHERE id = ? AND edit_version = ? AND deleted_at IS NULL`,
[
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,
data.isMilestone ? 1 : 0, data.estimatedHours || null, id, data.editVersion,
]
);
return findById(id);
return result.affectedRows ? findById(id) : null;
}
export async function updateProgress(id, progressPct) {
@@ -81,11 +81,11 @@ export async function updateProgress(id, progressPct) {
}
export async function softDelete(id, userId) {
await pool.query('UPDATE activities SET deleted_at = NOW(), deleted_by = ? WHERE id = ?', [userId, id]);
await pool.query('UPDATE activities SET deleted_at = NOW(), deleted_by = ?, edit_version = edit_version + 1 WHERE id = ?', [userId, id]);
}
export async function restore(id) {
await pool.query('UPDATE activities SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', [id]);
await pool.query('UPDATE activities SET deleted_at = NULL, deleted_by = NULL, edit_version = edit_version + 1 WHERE id = ?', [id]);
}
// Las tareas (tasks.activity_id) y los comentarios/adjuntos (entity_id de la actividad) se
@@ -94,7 +94,7 @@ export async function restore(id) {
// Las dependencias solo tienen sentido dentro del mismo proyecto (addDependency ya lo exige), asi
// que las existentes (en cualquiera de los dos sentidos) quedan invalidas al cambiar de proyecto.
export async function moveToProject(id, projectId) {
await pool.query('UPDATE activities SET project_id = ?, sort_order = 0 WHERE id = ?', [projectId, id]);
await pool.query('UPDATE activities SET project_id = ?, sort_order = 0, edit_version = edit_version + 1 WHERE id = ?', [projectId, id]);
await pool.query('DELETE FROM activity_dependencies WHERE activity_id = ? OR depends_on_activity_id = ?', [id, id]);
return findById(id);
}
+3 -1
View File
@@ -5,6 +5,7 @@ import * as attachmentsRepo from '../attachments/repository.js';
import * as tasksRepo from '../tasks/repository.js';
import { recalcProjectProgress, canReadProject, canWriteProject } from '../projects/service.js';
import { HttpError } from '../../utils/http.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
import { logAudit } from '../../utils/audit.js';
import { ROLES } from '../../middleware/auth.js';
import { createNotification } from '../notifications/service.js';
@@ -84,8 +85,9 @@ export async function updateActivity(id, payload, currentUser) {
? activity.responsible_user_id
: (payload.responsibleUserId ?? activity.responsible_user_id);
requireEditVersion(payload.editVersion);
const taskCount = await repo.countTasks(id);
const updated = await repo.update(id, { ...payload, responsibleUserId: nextResponsible });
const updated = requireUpdated(await repo.update(id, { ...payload, responsibleUserId: nextResponsible }));
if (taskCount === 0 && payload.progressPct !== undefined) {
const pct = Math.max(0, Math.min(100, Number(payload.progressPct)));
+17 -2
View File
@@ -24,21 +24,36 @@ export const getAttachments = asyncHandler(async (req, res) => {
ok(res, await repo.findByEntity(entityType, Number(entityId)));
});
export const preflightAttachment = asyncHandler(async (req, res, next) => {
const { entityType, entityId } = req.query;
if (!VALID_ENTITY_TYPES.has(entityType) || !Number.isSafeInteger(Number(entityId)) || Number(entityId) < 1) throw new HttpError(400, 'entityType y entityId son obligatorios');
if (req.currentUser.roleCode === ROLES.VIEWER) throw new HttpError(403, 'Tu rol es de solo lectura');
const project = await getProjectOr404(entityType, Number(entityId));
if (!(await canWriteProject(req.currentUser, project))) throw new HttpError(404, 'No encontrado');
next();
});
export const postAttachment = asyncHandler(async (req, res) => {
const { entityType, entityId } = req.body || {};
const { entityType, entityId } = req.query;
let attachment;
try {
if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) throw new HttpError(400, 'entityType y entityId son obligatorios');
if (req.currentUser.roleCode === ROLES.VIEWER) throw new HttpError(403, 'Tu rol es de solo lectura');
const project = await getProjectOr404(entityType, Number(entityId));
if (!(await canWriteProject(req.currentUser, project))) throw new HttpError(403, 'No tienes permisos de escritura en este proyecto');
if (!req.file) throw new HttpError(400, 'Archivo obligatorio');
const attachment = await repo.create({
attachment = await repo.create({
entityType, entityId: Number(entityId),
filename: req.file.filename, originalName: req.file.originalname,
mimeType: req.file.mimetype, size: req.file.size, uploadedBy: req.currentUser.id,
});
await logAudit({ userId: req.currentUser.id, action: 'upload', entityType, entityId: Number(entityId), detail: { attachmentId: attachment.id } });
ok(res, attachment, 201);
} catch (err) {
if (req.file && !attachment) await fs.promises.unlink(req.file.path).catch(() => {});
throw err;
}
});
export const downloadAttachment = asyncHandler(async (req, res) => {
+2 -2
View File
@@ -1,13 +1,13 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { upload } from '../../middleware/upload.js';
import { getAttachments, postAttachment, downloadAttachment, deleteAttachment } from './controller.js';
import { getAttachments, postAttachment, preflightAttachment, downloadAttachment, deleteAttachment } from './controller.js';
const router = Router();
router.use(requireAuth);
router.get('/', getAttachments);
router.post('/', upload.single('file'), postAttachment);
router.post('/', preflightAttachment, upload.single('file'), postAttachment);
router.get('/:id/download', downloadAttachment);
router.delete('/:id', deleteAttachment);
+36 -32
View File
@@ -7,6 +7,7 @@ import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { sendMail } from '../../utils/mailer.js';
import { createNotification } from '../notifications/service.js';
import { verificationEmail, recoveryEmail } from '../../utils/emailHtml.js';
const STATUS_MESSAGES = {
pending_verification: 'Debes confirmar tu correo antes de iniciar sesion. Revisa tu bandeja de entrada.',
@@ -20,7 +21,7 @@ function newToken() {
export async function login(email, password) {
const [rows] = await pool.query(
`SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.status, u.department_id,
`SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.status, u.department_id, u.session_version,
r.code AS role_code, r.name AS role_name
FROM users u JOIN roles r ON r.id = u.role_id
WHERE u.email = ? LIMIT 1`,
@@ -35,7 +36,7 @@ export async function login(email, password) {
if (STATUS_MESSAGES[user.status]) throw new HttpError(403, STATUS_MESSAGES[user.status]);
if (!user.is_active) throw new HttpError(403, 'Cuenta inactiva. Contacta a un administrador.');
const token = jwt.sign({ sub: user.id }, env.jwtSecret, { expiresIn: env.jwtExpires });
const token = jwt.sign({ sub: user.id, sv: user.session_version }, env.jwtSecret, { expiresIn: env.jwtExpires });
await logAudit({ userId: user.id, action: 'login', entityType: 'auth', entityId: user.id });
@@ -59,40 +60,43 @@ export async function register({ name, email, password }) {
const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
if (existing.length) throw new HttpError(400, 'Ya existe una cuenta con ese correo');
const [[{ total }]] = await pool.query('SELECT COUNT(*) AS total FROM users');
const passwordHash = await bcrypt.hash(password, 12);
if (total === 0) {
// Primer usuario del sistema: superadmin, sin correo ni aprobacion (no hay quien lo apruebe).
const [superadminRole] = await pool.query("SELECT id FROM roles WHERE code = 'superadmin'");
const [result] = await pool.query(
`INSERT INTO users (name, email, password_hash, role_id, is_active, status, email_verified_at)
VALUES (?, ?, ?, ?, 1, 'active', NOW())`,
[name, email, passwordHash, superadminRole[0].id]
const connection = await pool.getConnection();
let bootstrap;
let result;
let verificationToken;
try {
await connection.beginTransaction();
// El bloqueo de la fila de roles serializa incluso los registros con correos distintos.
const [roles] = await connection.query("SELECT id, code FROM roles WHERE code IN ('superadmin', 'collaborator') FOR UPDATE");
const [[{ total }]] = await connection.query('SELECT COUNT(*) AS total FROM users');
bootstrap = total === 0;
const roleId = roles.find((role) => role.code === (bootstrap ? 'superadmin' : 'collaborator'))?.id;
if (!roleId) throw new Error('Rol de registro no configurado');
verificationToken = bootstrap ? null : newToken();
[result] = await connection.query(
`INSERT INTO users (name, email, password_hash, role_id, is_active, status, email_verified_at, verification_token, verification_token_expires)
VALUES (?, ?, ?, ?, ?, ?, ${bootstrap ? 'NOW()' : 'NULL'}, ?, ${bootstrap ? 'NULL' : 'DATE_ADD(NOW(), INTERVAL 24 HOUR)'})`,
[name, email, passwordHash, roleId, bootstrap ? 1 : 0, bootstrap ? 'active' : 'pending_verification', verificationToken]
);
await connection.commit();
} catch (err) {
await connection.rollback();
if (err.code === 'ER_DUP_ENTRY') throw new HttpError(400, 'Ya existe una cuenta con ese correo');
throw err;
} finally { connection.release(); }
if (bootstrap) {
await logAudit({ userId: result.insertId, action: 'register_bootstrap_superadmin', entityType: 'user', entityId: result.insertId });
const token = jwt.sign({ sub: result.insertId }, env.jwtSecret, { expiresIn: env.jwtExpires });
return {
bootstrap: true,
token,
user: { id: result.insertId, name, email, roleCode: 'superadmin', roleName: 'Super Administrador', departmentId: null },
};
const token = jwt.sign({ sub: result.insertId, sv: 1 }, env.jwtSecret, { expiresIn: env.jwtExpires });
return { bootstrap: true, token, user: { id: result.insertId, name, email, roleCode: 'superadmin', roleName: 'Super Administrador', departmentId: null } };
}
const [collaboratorRole] = await pool.query("SELECT id FROM roles WHERE code = 'collaborator'");
const verificationToken = newToken();
const [result] = await pool.query(
`INSERT INTO users (name, email, password_hash, role_id, is_active, status, verification_token, verification_token_expires)
VALUES (?, ?, ?, ?, 0, 'pending_verification', ?, DATE_ADD(NOW(), INTERVAL 24 HOUR))`,
[name, email, passwordHash, collaboratorRole[0].id, verificationToken]
);
await logAudit({ userId: result.insertId, action: 'register', entityType: 'user', entityId: result.insertId });
const verifyUrl = `${env.corsOrigin}/verificar-correo?token=${verificationToken}`;
await sendMail({
to: email,
subject: 'Confirma tu correo — project.linkedcorp.net',
html: `<p>Hola ${name},</p><p>Confirma tu correo para continuar tu registro:</p><p><a href="${verifyUrl}">${verifyUrl}</a></p><p>Este enlace vence en 24 horas.</p>`,
html: verificationEmail(name, verifyUrl),
});
return { bootstrap: false };
@@ -160,7 +164,7 @@ export async function acceptInvite(token, name, password) {
const passwordHash = await bcrypt.hash(password, 12);
await pool.query(
`UPDATE users SET name = ?, password_hash = ?, is_active = 1, status = 'active', email_verified_at = NOW(),
`UPDATE users SET name = ?, password_hash = ?, is_active = 1, status = 'active', edit_version = edit_version + 1, email_verified_at = NOW(),
verification_token = NULL, verification_token_expires = NULL
WHERE id = ?`,
[name, passwordHash, user.id]
@@ -168,11 +172,11 @@ export async function acceptInvite(token, name, password) {
await logAudit({ userId: user.id, action: 'accept_invite', entityType: 'user', entityId: user.id });
const [full] = await pool.query(
`SELECT u.id, u.name, u.email, u.department_id, r.code AS role_code, r.name AS role_name
`SELECT u.id, u.name, u.email, u.department_id, u.session_version, r.code AS role_code, r.name AS role_name
FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`,
[user.id]
);
const jwtToken = jwt.sign({ sub: user.id }, env.jwtSecret, { expiresIn: env.jwtExpires });
const jwtToken = jwt.sign({ sub: user.id, sv: full[0].session_version }, env.jwtSecret, { expiresIn: env.jwtExpires });
return {
token: jwtToken,
user: {
@@ -200,7 +204,7 @@ export async function forgotPassword(email) {
await sendMail({
to: email,
subject: 'Recuperar contraseña — project.linkedcorp.net',
html: `<p>Hola ${user.name},</p><p>Para elegir una nueva clave entra a este enlace (vence en 1 hora):</p><p><a href="${resetUrl}">${resetUrl}</a></p><p>Si no pediste esto, ignora el correo.</p>`,
html: recoveryEmail(user.name, resetUrl),
});
}
@@ -217,7 +221,7 @@ export async function resetPassword(token, newPassword) {
const passwordHash = await bcrypt.hash(newPassword, 12);
await pool.query(
`UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?`,
`UPDATE users SET password_hash = ?, session_version = session_version + 1, reset_token = NULL, reset_token_expires = NULL WHERE id = ?`,
[passwordHash, user.id]
);
await logAudit({ userId: user.id, action: 'reset_password', entityType: 'user', entityId: user.id });
@@ -236,6 +240,6 @@ export async function changePassword(userId, currentPassword, newPassword) {
if (!validPassword) throw new HttpError(400, 'La clave actual no es correcta');
const passwordHash = await bcrypt.hash(newPassword, 12);
await pool.query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, userId]);
await pool.query('UPDATE users SET password_hash = ?, session_version = session_version + 1 WHERE id = ?', [passwordHash, userId]);
await logAudit({ userId, action: 'change_password', entityType: 'user', entityId: userId });
}
+3 -1
View File
@@ -1,6 +1,7 @@
import { ok, fail, asyncHandler } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { CATALOGS } from './repository.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
import * as repo from './repository.js';
function validKey(req, res) {
@@ -29,7 +30,8 @@ export const postCatalog = asyncHandler(async (req, res) => {
export const putCatalog = asyncHandler(async (req, res) => {
const key = validKey(req, res);
if (!key) return;
const item = await repo.update(key, Number(req.params.id), req.body);
requireEditVersion(req.body.editVersion);
const item = requireUpdated(await repo.update(key, Number(req.params.id), req.body));
await logAudit({ userId: req.currentUser.id, action: 'update', entityType: `catalog:${key}`, entityId: item.id });
ok(res, item);
});
+2 -2
View File
@@ -37,8 +37,8 @@ export async function update(catalogKey, id, payload) {
const cfg = CATALOGS[catalogKey];
const cols = cfg.fields.filter((f) => payload[f] !== undefined);
const setClause = cols.map((c) => `${c} = ?`).join(', ');
await pool.query(`UPDATE ${cfg.table} SET ${setClause} WHERE id = ?`, [...cols.map((c) => payload[c]), id]);
return { id, ...payload };
const [result] = await pool.query(`UPDATE ${cfg.table} SET ${setClause}, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?`, [...cols.map((c) => payload[c]), id, payload.editVersion]);
return result.affectedRows ? { id, ...payload, edit_version: payload.editVersion + 1 } : null;
}
export async function remove(catalogKey, id) {
+5 -2
View File
@@ -37,7 +37,7 @@ export const postComment = asyncHandler(async (req, res) => {
// Solo el autor, o superadmin/admin, puede editar/borrar -- nunca otro colaborador aunque tenga
// permiso de escritura en el proyecto (un comentario es responsabilidad de quien lo escribio).
function canManageComment(currentUser, comment) {
return comment.user_id === currentUser.id || [ROLES.SUPERADMIN, ROLES.ADMIN].includes(currentUser.roleCode);
return currentUser.roleCode !== ROLES.VIEWER && (comment.user_id === currentUser.id || [ROLES.SUPERADMIN, ROLES.ADMIN].includes(currentUser.roleCode));
}
export const putComment = asyncHandler(async (req, res) => {
@@ -46,7 +46,10 @@ export const putComment = asyncHandler(async (req, res) => {
if (!canManageComment(req.currentUser, comment)) throw new HttpError(403, 'Solo quien escribio el comentario puede editarlo');
const message = (req.body?.message || '').trim();
if (!message) throw new HttpError(400, 'El mensaje es obligatorio');
const updated = await repo.update(comment.id, message);
const editVersion = req.body?.editVersion;
if (!Number.isSafeInteger(editVersion) || editVersion < 1) throw new HttpError(428, 'Falta la version del comentario');
const updated = await repo.update(comment.id, message, editVersion);
if (!updated) throw new HttpError(409, 'El comentario cambio en el servidor. Recarga antes de guardar');
await logAudit({ userId: req.currentUser.id, action: 'comment_edit', entityType: comment.entity_type, entityId: comment.entity_id, detail: { commentId: comment.id } });
ok(res, updated);
});
+5 -5
View File
@@ -2,7 +2,7 @@ import { pool } from '../../config/db.js';
export async function findByEntity(entityType, entityId) {
const [rows] = await pool.query(
`SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.created_at, c.updated_at
`SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.edit_version, c.created_at, c.updated_at
FROM comments c JOIN users u ON u.id = c.user_id
WHERE c.entity_type = ? AND c.entity_id = ? ORDER BY c.created_at`,
[entityType, entityId]
@@ -12,16 +12,16 @@ export async function findByEntity(entityType, entityId) {
export async function findById(id) {
const [rows] = await pool.query(
`SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.created_at, c.updated_at
`SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.edit_version, c.created_at, c.updated_at
FROM comments c JOIN users u ON u.id = c.user_id WHERE c.id = ?`,
[id]
);
return rows[0] || null;
}
export async function update(id, message) {
await pool.query('UPDATE comments SET message = ? WHERE id = ?', [message, id]);
return findById(id);
export async function update(id, message, editVersion) {
const [result] = await pool.query('UPDATE comments SET message = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?', [message, id, editVersion]);
return result.affectedRows ? findById(id) : null;
}
export async function remove(id) {
+3 -1
View File
@@ -8,10 +8,12 @@ export const getCompany = asyncHandler(async (req, res) => ok(res, await service
export const getCompanyLogo = asyncHandler(async (req, res) => {
const file = await service.getCompanyLogoFile();
if (!file) throw new HttpError(404, 'No hay logo configurado');
res.set('X-Content-Type-Options', 'nosniff');
res.set('Content-Security-Policy', "default-src 'none'; sandbox");
// sendFile exige ruta absoluta (a diferencia de res.download, que la resuelve sola).
res.type(file.mime).sendFile(path.resolve(file.filePath));
});
export const putCompany = asyncHandler(async (req, res) => {
ok(res, await service.updateCompanyInfo({ name: req.body.name, file: req.file }, req.currentUser.id));
ok(res, await service.updateCompanyInfo({ name: req.body.name, file: req.file, editVersion: Number(req.body.editVersion) }, req.currentUser.id));
});
+28 -9
View File
@@ -3,16 +3,18 @@ import path from 'node:path';
import * as settingsRepo from '../settings/repository.js';
import { logoDir } from '../../middleware/uploadLogo.js';
import { logAudit } from '../../utils/audit.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
import { HttpError } from '../../utils/http.js';
const COMPANY_KEYS = ['company_name', 'company_logo_filename', 'company_logo_mime'];
const COMPANY_KEYS = ['company_name', 'company_logo_filename', 'company_logo_mime', 'company_edit_version'];
const DEFAULT_NAME = 'LinkedCorp';
export async function getCompanyInfo() {
const values = await settingsRepo.getAll(COMPANY_KEYS);
return {
name: values.company_name || DEFAULT_NAME,
hasLogo: !!values.company_logo_filename,
edit_version: Number(values.company_edit_version),
hasLogo: !!values.company_logo_filename && ['image/png', 'image/jpeg', 'image/webp'].includes(values.company_logo_mime),
// El propio nombre de archivo (unico por subida) sirve de version para cache-busting del logo.
logoVersion: values.company_logo_filename || null,
};
@@ -21,28 +23,45 @@ export async function getCompanyInfo() {
export async function getCompanyLogoFile() {
const values = await settingsRepo.getAll(['company_logo_filename', 'company_logo_mime']);
if (!values.company_logo_filename) return null;
if (!['image/png', 'image/jpeg', 'image/webp'].includes(values.company_logo_mime)) return null;
const filePath = path.join(logoDir, values.company_logo_filename);
if (!fs.existsSync(filePath)) return null;
return { filePath, mime: values.company_logo_mime || 'application/octet-stream' };
}
export async function updateCompanyInfo({ name, file }, actorId) {
export async function updateCompanyInfo({ name, file, editVersion }, actorId) {
requireEditVersion(editVersion);
const entries = {};
let oldLogoFilename;
if (name !== undefined) {
const trimmed = (name || '').trim();
if (!trimmed) throw new HttpError(400, 'El nombre de la empresa es obligatorio');
entries.company_name = trimmed;
}
if (file) {
const values = await settingsRepo.getAll(['company_logo_filename']);
if (values.company_logo_filename) {
const oldPath = path.join(logoDir, values.company_logo_filename);
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
const bytes = await fs.promises.readFile(file.path);
const png = bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
const jpeg = bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
const webp = bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP';
const actualMime = png ? 'image/png' : jpeg ? 'image/jpeg' : webp ? 'image/webp' : null;
if (!actualMime || file.mimetype !== actualMime) {
await fs.promises.unlink(file.path).catch(() => {});
throw new HttpError(400, 'El contenido del logo no coincide con PNG, JPG o WEBP');
}
const values = await settingsRepo.getAll(['company_logo_filename']);
oldLogoFilename = values.company_logo_filename;
entries.company_logo_filename = file.filename;
entries.company_logo_mime = file.mimetype;
entries.company_logo_mime = actualMime;
}
try {
requireUpdated(await settingsRepo.setManyIfVersion(entries, 'company_edit_version', editVersion));
} catch (err) {
if (file) await fs.promises.unlink(file.path).catch(() => {});
throw err;
}
if (file && oldLogoFilename && oldLogoFilename !== file.filename) {
await fs.promises.unlink(path.join(logoDir, oldLogoFilename)).catch(() => {});
}
if (Object.keys(entries).length) await settingsRepo.setMany(entries);
await logAudit({ userId: actorId, action: 'update', entityType: 'settings:company', entityId: null });
return getCompanyInfo();
}
@@ -1,7 +1,7 @@
import { pool } from '../../config/db.js';
export async function findAllFlat() {
const [rows] = await pool.query('SELECT id, name, parent_id FROM departments ORDER BY name');
const [rows] = await pool.query('SELECT id, name, parent_id, edit_version FROM departments ORDER BY name');
return rows;
}
@@ -13,9 +13,9 @@ export async function create({ name, parentId }) {
return { id: result.insertId, name, parent_id: parentId || null };
}
export async function update(id, { name, parentId }) {
await pool.query('UPDATE departments SET name = ?, parent_id = ? WHERE id = ?', [name, parentId || null, id]);
return { id, name, parent_id: parentId || null };
export async function update(id, { name, parentId, editVersion }) {
const [result] = await pool.query('UPDATE departments SET name = ?, parent_id = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?', [name, parentId || null, id, editVersion]);
return result.affectedRows ? { id, name, parent_id: parentId || null, edit_version: editVersion + 1 } : null;
}
export async function remove(id) {
+3 -1
View File
@@ -1,5 +1,6 @@
import * as repo from './repository.js';
import { HttpError } from '../../utils/http.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
import { logAudit } from '../../utils/audit.js';
function toTree(flat) {
@@ -30,7 +31,8 @@ export async function createDepartment(payload, actorId) {
export async function updateDepartment(id, payload, actorId) {
if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio');
if (Number(payload.parentId) === Number(id)) throw new HttpError(400, 'Un departamento no puede ser su propio padre');
const dept = await repo.update(id, payload);
requireEditVersion(payload.editVersion);
const dept = requireUpdated(await repo.update(id, payload));
await logAudit({ userId: actorId, action: 'update', entityType: 'department', entityId: id });
return dept;
}
+60 -17
View File
@@ -3,10 +3,13 @@ import zlib from 'node:zlib';
import fs from 'node:fs';
import path from 'node:path';
import express from 'express';
import rateLimit from 'express-rate-limit';
import { promisify } from 'node:util';
import { Worker } from 'node:worker_threads';
import { env } from '../../config/env.js';
import { HttpError, asyncHandler } from '../../utils/http.js';
import * as repo from './repository.js';
import { parseInventoryText, normalizeInventory } from './normalizer.js';
import { redactUrl } from '../../utils/redactUrl.js';
// Ingesta del agente GLPI. NO usa JWT de usuario: cada instalacion lleva un token (guardado como
// hash en inventory_tokens) que el agente manda como password Basic, Bearer, X-Ingest-Token o
@@ -17,6 +20,8 @@ const MAX_INFLATED = MAX_BYTES * 4; // tope de inflado (anti zip-bomb)
const REPORT_EVERY_HOURS = 24;
const sha256 = (v) => crypto.createHash('sha256').update(v).digest('hex');
const gunzip = promisify(zlib.gunzip);
const uncompress = promisify(zlib.inflate);
function extractToken(req) {
if (req.headers['x-ingest-token']) return String(req.headers['x-ingest-token']).trim();
@@ -32,10 +37,10 @@ function extractToken(req) {
return null;
}
function inflate(buf) {
async function inflate(buf) {
const opts = { maxOutputLength: MAX_INFLATED };
if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b) return zlib.gunzipSync(buf, opts);
if (buf.length > 2 && buf[0] === 0x78) return zlib.inflateSync(buf, opts);
if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b) return gunzip(buf, opts);
if (buf.length > 2 && buf[0] === 0x78) return uncompress(buf, opts);
return buf;
}
@@ -46,24 +51,43 @@ function detectFormat(text) {
return null;
}
function saveRaw(deviceid, text, format) {
async function saveRaw(deviceid, text, format) {
const dir = path.join(env.storageRoot, 'inventories');
fs.mkdirSync(dir, { recursive: true });
await fs.promises.mkdir(dir, { recursive: true });
// Un archivo por equipo (se sobrescribe): solo interesa el ultimo reporte crudo para depurar.
const name = `${sha256(deviceid).slice(0, 24)}.${format}`;
fs.writeFileSync(path.join(dir, name), text);
await fs.promises.writeFile(path.join(dir, name), text);
return name;
}
function parseInWorker(text, format) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./parseWorker.js', import.meta.url), {
workerData: { text, format }, resourceLimits: { maxOldGenerationSizeMb: 256 },
});
let settled = false;
const timeout = setTimeout(() => worker.terminate(), 15_000);
function finish(error, value) {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (error) reject(error); else resolve(value);
}
worker.once('message', (value) => value.error ? finish(new Error(value.error)) : finish(null, value));
worker.once('error', (error) => finish(error));
worker.once('exit', (code) => { if (!settled) finish(new Error(`Parser interrumpido (${code})`)); });
});
}
const ok = (res, format) => (format === 'xml'
? res.type('application/xml').send('<?xml version="1.0" encoding="UTF-8"?><REPLY></REPLY>')
: res.json({ status: 'ok' }));
const receive = asyncHandler(async (req, res) => {
const token = extractToken(req);
const tokenRow = token ? await repo.findActiveTokenByHash(sha256(token)) : null;
const tokenRow = req.inventoryToken;
const token = req.inventoryTokenPresent;
// Log de diagnostico de TODA peticion del agente (sin imprimir el token): util para depurar la conexion.
console.log(`[inventario] ${req.method} ${req.originalUrl} desde ${req.ip} type=${req.headers['content-type'] || '-'} enc=${req.headers['content-encoding'] || '-'} bytes=${req.headers['content-length'] || '?'} token=${token ? (tokenRow ? 'valido' : 'INVALIDO') : 'ausente'}`);
console.log(`[inventario] ${req.method} ${redactUrl(req.originalUrl)} desde ${req.ip} type=${req.headers['content-type'] || '-'} enc=${req.headers['content-encoding'] || '-'} bytes=${req.headers['content-length'] || '?'} token=${token ? (tokenRow ? 'valido' : 'INVALIDO') : 'ausente'}`);
if (!tokenRow) {
// El agente (LWP) manda las credenciales solo despues de recibir este desafio Basic.
res.set('WWW-Authenticate', 'Basic realm="Project inventory"');
@@ -76,7 +100,7 @@ const receive = asyncHandler(async (req, res) => {
let text;
try {
text = inflate(body).toString('utf8');
text = (await inflate(body)).toString('utf8');
} catch {
throw new HttpError(400, 'Cuerpo comprimido invalido o demasiado grande');
}
@@ -85,7 +109,7 @@ const receive = asyncHandler(async (req, res) => {
let parsed;
try {
parsed = parseInventoryText(text, format);
parsed = await parseInWorker(text, format);
} catch {
throw new HttpError(400, 'No se pudo interpretar el inventario');
}
@@ -102,8 +126,8 @@ const receive = asyncHandler(async (req, res) => {
if (parsed.action && parsed.action !== 'inventory') return ok(res, format);
if (!parsed.deviceid) throw new HttpError(400, 'Inventario sin deviceid');
const normalized = normalizeInventory(parsed);
const rawFile = saveRaw(parsed.deviceid, text, format);
const normalized = parsed.normalized;
const rawFile = await saveRaw(parsed.deviceid, text, format);
const result = await repo.upsertInventory(parsed.deviceid, normalized, rawFile);
console.log(`[inventario] ${result.created ? 'nuevo' : 'actualizado'} #${result.id} ${normalized.device.hostname || parsed.deviceid} (${format}, ${result.changeCount} cambios)`);
return ok(res, format);
@@ -111,13 +135,32 @@ const receive = asyncHandler(async (req, res) => {
const router = express.Router();
const rawBody = express.raw({ type: () => true, limit: MAX_BYTES });
const limiter = rateLimit({ windowMs: 60_000, limit: 120, standardHeaders: 'draft-7', legacyHeaders: false });
let activeRequests = 0;
const preflight = asyncHandler(async (req, res, next) => {
const token = extractToken(req);
const tokenRow = token ? await repo.findActiveTokenByHash(sha256(token)) : null;
req.inventoryTokenPresent = !!token;
req.inventoryToken = tokenRow;
if (!tokenRow) {
res.set('WWW-Authenticate', 'Basic realm="Project inventory"');
throw new HttpError(401, 'No autorizado');
}
if (activeRequests >= 2) throw new HttpError(503, 'Ingesta ocupada; reintenta');
activeRequests++;
let released = false;
function release() { if (!released) { released = true; activeRequests--; } }
res.once('finish', release);
res.once('close', release);
next();
});
// Ruta propia + alias de la ruta nativa de GLPI (para no parchear el agente).
router.post('/api/inventory', rawBody, receive);
router.post('/front/inventory.php', rawBody, receive);
router.post('/api/inventory', limiter, preflight, rawBody, receive);
router.post('/front/inventory.php', limiter, preflight, rawBody, receive);
// Cualquier otro metodo/ruta que el agente pruebe bajo estas rutas queda registrado.
router.all(['/api/inventory', '/front/inventory.php'], (req, res) => {
console.log(`[inventario] ${req.method} ${req.originalUrl} desde ${req.ip} (metodo no soportado)`);
console.log(`[inventario] ${req.method} ${redactUrl(req.originalUrl)} desde ${req.ip} (metodo no soportado)`);
res.status(405).json({ success: false, data: null, error: 'Usa POST' });
});
@@ -0,0 +1,10 @@
import { parentPort, workerData } from 'node:worker_threads';
import { parseInventoryText, normalizeInventory } from './normalizer.js';
try {
const parsed = parseInventoryText(workerData.text, workerData.format);
parentPort.postMessage({ action: parsed.action, deviceid: parsed.deviceid,
normalized: parsed.action === 'inventory' || !parsed.action ? normalizeInventory(parsed) : null });
} catch {
parentPort.postMessage({ error: 'No se pudo interpretar el inventario' });
}
+5 -4
View File
@@ -217,11 +217,12 @@ export async function findById(id) {
return detail;
}
export async function updateManual(id, { status, assignedUserId, departmentId, notes }) {
await pool.query(
'UPDATE devices SET status = ?, assigned_user_id = ?, department_id = ?, notes = ? WHERE id = ?',
[status, assignedUserId, departmentId, notes, id]
export async function updateManual(id, { status, assignedUserId, departmentId, notes, editVersion }) {
const [result] = await pool.query(
'UPDATE devices SET status = ?, assigned_user_id = ?, department_id = ?, notes = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?',
[status, assignedUserId, departmentId, notes, id, editVersion]
);
return result.affectedRows > 0;
}
export async function findRawFile(id) {
+11 -13
View File
@@ -1,5 +1,5 @@
import { Router } from 'express';
import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js';
import { requireAuth, requireRole, CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
import { fail } from '../../utils/http.js';
import { getAccess as getAccessFor } from './service.js';
import {
@@ -11,8 +11,6 @@ const router = Router();
// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente y el compartir, solo
// admin/superadmin. Otros usuarios pueden tener acceso de SOLO LECTURA compartido (device_viewers).
const CAN_EDIT = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER];
const CAN_MANAGE = [ROLES.SUPERADMIN, ROLES.ADMIN];
async function canView(req, res, next) {
try {
@@ -27,17 +25,17 @@ router.get('/access', getAccess); // cualquier autenticado: el menu lo usa para
router.get('/dashboard', canView, getDashboard);
router.get('/software', canView, getSoftware);
router.get('/software/devices', canView, getSoftwareDevices);
router.get('/viewers', requireRole(...CAN_MANAGE), getViewers);
router.post('/viewers', requireRole(...CAN_MANAGE), postViewer);
router.delete('/viewers/:userId', requireRole(...CAN_MANAGE), deleteViewer);
router.get('/tokens', requireRole(...CAN_MANAGE), getTokens);
router.post('/tokens', requireRole(...CAN_MANAGE), postToken);
router.get('/tokens/:id/secret', requireRole(...CAN_MANAGE), getTokenSecret);
router.patch('/tokens/:id', requireRole(...CAN_MANAGE), patchToken);
router.delete('/tokens/:id', requireRole(...CAN_MANAGE), deleteToken);
router.get('/viewers', requireRole(...CAN_MANAGE_DEVICES), getViewers);
router.post('/viewers', requireRole(...CAN_MANAGE_DEVICES), postViewer);
router.delete('/viewers/:userId', requireRole(...CAN_MANAGE_DEVICES), deleteViewer);
router.get('/tokens', requireRole(...CAN_MANAGE_DEVICES), getTokens);
router.post('/tokens', requireRole(...CAN_MANAGE_DEVICES), postToken);
router.get('/tokens/:id/secret', requireRole(...CAN_MANAGE_DEVICES), getTokenSecret);
router.patch('/tokens/:id', requireRole(...CAN_MANAGE_DEVICES), patchToken);
router.delete('/tokens/:id', requireRole(...CAN_MANAGE_DEVICES), deleteToken);
router.get('/', canView, getDevices);
router.get('/:id', canView, getDevice);
router.get('/:id/raw', requireRole(...CAN_EDIT), getRaw);
router.put('/:id', requireRole(...CAN_EDIT), putDevice);
router.get('/:id/raw', requireRole(...CAN_EDIT_DEVICES), getRaw);
router.put('/:id', requireRole(...CAN_EDIT_DEVICES), putDevice);
export default router;
+7 -6
View File
@@ -3,9 +3,11 @@ import fs from 'node:fs';
import path from 'node:path';
import { env } from '../../config/env.js';
import { HttpError } from '../../utils/http.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
import { logAudit } from '../../utils/audit.js';
import { encrypt, decrypt } from '../../utils/crypto.js';
import * as repo from './repository.js';
import { CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
const STATUSES = ['active', 'retired'];
const STALE_DAYS = 7;
@@ -34,7 +36,8 @@ export async function updateDevice(id, data, actor) {
departmentId: data.departmentId === undefined ? current.department_id : data.departmentId || null,
notes: data.notes === undefined ? current.notes : (String(data.notes).trim() || null),
};
await repo.updateManual(id, next);
requireEditVersion(data.editVersion);
requireUpdated(await repo.updateManual(id, { ...next, editVersion: data.editVersion }));
await logAudit({ userId: actor.id, action: 'update', entityType: 'device', entityId: id, detail: { hostname: current.hostname, ...next } });
return getDevice(id);
}
@@ -51,14 +54,12 @@ export const listSoftware = (q, all) => repo.listSoftware({ search: q?.trim() ||
export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name);
export const getDashboard = () => repo.dashboard(STALE_DAYS);
const FULL_ACCESS_ROLES = ['superadmin', 'admin', 'it_manager'];
const MANAGE_ROLES = ['superadmin', 'admin'];
// Que puede hacer este usuario en Equipos: ver (rol de TI o compartido), editar, gestionar (agentes/compartir).
export async function getAccess(user) {
const full = FULL_ACCESS_ROLES.includes(user.roleCode);
const full = CAN_EDIT_DEVICES.includes(user.roleCode);
const canView = full || (await repo.isViewer(user.id));
return { canView, canEdit: full, canManage: MANAGE_ROLES.includes(user.roleCode) };
return { canView, canEdit: full, canManage: CAN_MANAGE_DEVICES.includes(user.roleCode) };
}
export const listViewers = () => repo.listViewers();
@@ -66,7 +67,7 @@ export const listViewers = () => repo.listViewers();
export async function addViewer(userId, actor) {
const target = await repo.findUserBrief(userId);
if (!target || !target.is_active) throw new HttpError(404, 'Usuario no encontrado');
if (FULL_ACCESS_ROLES.includes(target.role_code)) throw new HttpError(400, 'Ese usuario ya tiene acceso completo por su rol');
if (CAN_EDIT_DEVICES.includes(target.role_code)) throw new HttpError(400, 'Ese usuario ya tiene acceso completo por su rol');
await repo.addViewer(userId, actor.id);
await logAudit({ userId: actor.id, action: 'share', entityType: 'devices', entityId: userId, detail: { name: target.name } });
return repo.listViewers();
+3 -2
View File
@@ -2,6 +2,7 @@ import jwt from 'jsonwebtoken';
import * as repo from './repository.js';
import { env } from '../../config/env.js';
import { HttpError } from '../../utils/http.js';
import { redactUrl } from '../../utils/redactUrl.js';
// Severidad SIEMPRE automatica, nunca etiquetada a mano (mismo criterio que la criticidad del
// panel de Actualizaciones): backend y errores de render de React "alta" (rompen toda la
@@ -22,7 +23,7 @@ export async function logBackendError(err, req) {
severity: 'alta',
message: (err?.message || String(err)).slice(0, 2000),
stack: (err?.stack || '').slice(0, 5000) || null,
url: req?.originalUrl?.slice(0, 500) || null,
url: redactUrl(req?.originalUrl).slice(0, 500) || null,
method: req?.method || null,
userId: req?.currentUser?.id || null,
userAgent: (req?.headers?.['user-agent'] || '').slice(0, 255) || null,
@@ -53,7 +54,7 @@ export async function logFrontendError(payload, req) {
severity: normalizeSeverity(payload.severity),
message,
stack: (payload.stack || '').slice(0, 5000) || null,
url: (payload.url || '').slice(0, 500) || null,
url: redactUrl(payload.url || '').slice(0, 500) || null,
method: null,
userId: decodeOptionalUserId(req),
userAgent: (req.headers['user-agent'] || '').slice(0, 255) || null,
+1 -1
View File
@@ -10,7 +10,7 @@ export const postNote = asyncHandler(async (req, res) => {
});
export const putNote = asyncHandler(async (req, res) => {
ok(res, await service.updateNote(Number(req.params.id), req.body.content, req.currentUser));
ok(res, await service.updateNote(Number(req.params.id), req.body.content, req.body.editVersion, req.currentUser));
});
export const deleteNote = asyncHandler(async (req, res) => {
+5 -5
View File
@@ -6,7 +6,7 @@ import { pool } from '../../config/db.js';
// su orden manual), compartidas despues (por fecha -- nunca arrastrables).
export async function findVisibleToUser(userId) {
const [rows] = await pool.query(
`SELECT n.id, n.user_id, n.content, n.created_at, n.updated_at,
`SELECT n.id, n.user_id, n.content, n.edit_version, n.created_at, n.updated_at,
(n.user_id = ?) AS is_owner, u.name AS owner_name,
(SELECT COUNT(*) FROM note_shares ns WHERE ns.note_id = n.id) AS share_count
FROM notes n
@@ -29,7 +29,7 @@ export async function reorder(userId, ids) {
}
export async function findById(id) {
const [rows] = await pool.query('SELECT id, user_id, content, created_at, updated_at FROM notes WHERE id = ?', [id]);
const [rows] = await pool.query('SELECT id, user_id, content, edit_version, created_at, updated_at FROM notes WHERE id = ?', [id]);
return rows[0] || null;
}
@@ -38,9 +38,9 @@ export async function create(userId, content) {
return findById(result.insertId);
}
export async function update(id, content) {
await pool.query('UPDATE notes SET content = ? WHERE id = ?', [content, id]);
return findById(id);
export async function update(id, content, editVersion) {
const [result] = await pool.query('UPDATE notes SET content = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?', [content, id, editVersion]);
return result.affectedRows ? findById(id) : null;
}
export async function remove(id) {
+4 -2
View File
@@ -1,6 +1,7 @@
import * as repo from './repository.js';
import * as usersRepo from '../users/repository.js';
import { HttpError } from '../../utils/http.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
import { logAudit } from '../../utils/audit.js';
// Siempre privadas para editar/borrar/compartir: cada quien solo toca sus propias notas, sin
@@ -19,11 +20,12 @@ export async function createNote(content, currentUser) {
return repo.create(currentUser.id, trimmed);
}
export async function updateNote(id, content, currentUser) {
export async function updateNote(id, content, editVersion, currentUser) {
await getOwnNoteOr404(id, currentUser);
const trimmed = (content || '').trim();
if (!trimmed) throw new HttpError(400, 'El contenido es obligatorio');
return repo.update(id, trimmed);
requireEditVersion(editVersion);
return requireUpdated(await repo.update(id, trimmed, editVersion));
}
export async function removeNote(id, currentUser) {
+4 -2
View File
@@ -2,10 +2,12 @@ import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
export const getProjects = asyncHandler(async (req, res) => {
const { statusId, priorityId, ownerUserId, trash } = req.query;
ok(res, await service.listProjects({ statusId, priorityId, ownerUserId, trash: trash === 'true' }, req.currentUser));
const { statusId, priorityId, ownerUserId, trash, page, pageSize, dueSoon, progress, q } = req.query;
ok(res, await service.listProjects({ statusId, priorityId, ownerUserId, trash: trash === 'true', page, pageSize, dueSoon: dueSoon === 'true', progress, q }, req.currentUser));
});
export const getProjectOptions = asyncHandler(async (req, res) => ok(res, await service.listProjectOptions(req.currentUser)));
export const getProject = asyncHandler(async (req, res) => {
ok(res, await service.getProject(Number(req.params.id), req.currentUser));
});
+25 -11
View File
@@ -8,7 +8,7 @@ const LIST_SELECT = `
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, p.deleted_at, p.deleted_by,
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,
@@ -38,12 +38,16 @@ function castCounts(row) {
};
}
export async function findAll({ statusId, priorityId, ownerUserId, memberUserId, trash } = {}) {
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) {
@@ -51,8 +55,18 @@ export async function findAll({ statusId, priorityId, ownerUserId, memberUserId,
params.push(memberUserId);
}
const where = `WHERE ${clauses.join(' AND ')}`;
const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.created_at DESC`, params);
return rows.map(castCounts);
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 } = {}) {
@@ -62,11 +76,11 @@ export async function findById(id, { includeDeleted = false } = {}) {
}
export async function softDelete(id, userId) {
await pool.query('UPDATE projects SET deleted_at = NOW(), deleted_by = ? WHERE id = ? AND deleted_at IS NULL', [userId, id]);
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 WHERE id = ?', [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) {
@@ -106,21 +120,21 @@ export async function getDefaultStatusId() {
}
export async function update(id, data) {
await pool.query(
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 = ?
WHERE 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.riskLevel || 'low', data.notes || null, id, data.editVersion,
]
);
return findById(id);
return result.affectedRows ? findById(id) : null;
}
export async function updateProgress(id, progressPct) {
+2 -1
View File
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import {
getProjects, getProject, postProject, putProject, deleteProject, postProjectRestore,
getProjects, getProjectOptions, getProject, postProject, putProject, deleteProject, postProjectRestore,
deleteProjectPermanent, postMember, putMemberRole, deleteMember,
} from './controller.js';
import { getGantt } from '../dashboard/controller.js';
@@ -11,6 +11,7 @@ const router = Router();
router.use(requireAuth);
router.get('/', getProjects);
router.get('/options', getProjectOptions);
router.post('/', postProject);
router.get('/:id', getProject);
router.put('/:id', putProject);
+13 -2
View File
@@ -22,6 +22,7 @@ export async function canReadProject(user, project) {
// tercer nivel "administrador de proyecto") que se sigue respetando como escritura en filas
// viejas, pero ya no se asigna desde la UI.
export async function canWriteProject(user, project) {
if (user.roleCode === ROLES.VIEWER) return false;
if (FULL_ACCESS_ROLES.includes(user.roleCode)) return true;
const membership = await repo.getMembership(project.id, user.id);
if (['owner', 'admin', 'write'].includes(membership)) return true;
@@ -37,18 +38,22 @@ export function isProjectOwner(user, project) {
// un admin del proyecto ni un rol global de administracion pueden compartir el proyecto de otra
// persona sin que el dueño lo autorice (acordado con el usuario).
export function canManageMembers(user, project) {
return isProjectOwner(user, project);
return user.roleCode !== ROLES.VIEWER && isProjectOwner(user, project);
}
// Borrar el PROYECTO (no su contenido) es exclusivo del dueño o de un rol global de
// administracion -- un colaborador con permiso de Escritura puede borrar actividades/tareas
// dentro del proyecto, pero nunca el proyecto en si (acordado con el usuario).
export function canDeleteProject(user, project) {
return FULL_ACCESS_ROLES.includes(user.roleCode) || isProjectOwner(user, project);
return user.roleCode !== ROLES.VIEWER && (FULL_ACCESS_ROLES.includes(user.roleCode) || isProjectOwner(user, project));
}
// Solo el superadmin puede ver la papelera (proyectos eliminados logicamente).
export async function listProjects(filters, currentUser) {
const page = Number(filters.page || 1);
const pageSize = Number(filters.pageSize || 20);
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100 || !Number.isSafeInteger((page - 1) * pageSize)) throw new HttpError(400, 'Paginacion invalida');
filters = { ...filters, page, pageSize, q: String(filters.q || '').trim().slice(0, 100) };
if (filters.trash && currentUser?.roleCode !== ROLES.SUPERADMIN) {
throw new HttpError(403, 'Solo el superadministrador puede ver la papelera');
}
@@ -59,6 +64,10 @@ export async function listProjects(filters, currentUser) {
return repo.findAll({ ...filters, memberUserId: currentUser.id });
}
export function listProjectOptions(currentUser) {
return repo.findOptions(FULL_ACCESS_ROLES.includes(currentUser.roleCode) ? null : currentUser.id);
}
// Usado por comments/attachments, que cuelgan de 'project' o 'activity' (entity_type/entity_id
// generico, ver BASE_DE_DATOS.md) -- resuelve a que proyecto pertenece la entidad para poder
// aplicar el mismo chequeo de lectura/escritura por membresia en los dos modulos.
@@ -106,7 +115,9 @@ export async function updateProject(id, payload, currentUser) {
if (!payload.name || !payload.priorityId || !payload.statusId) {
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
}
if (!Number.isSafeInteger(payload.editVersion) || payload.editVersion < 1) throw new HttpError(428, 'Falta la version del proyecto');
const updated = await repo.update(id, { ...payload, ownerUserId: payload.ownerUserId || project.owner_user_id });
if (!updated) throw new HttpError(409, 'El proyecto cambio en el servidor. Recarga antes de guardar');
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'project', entityId: id });
return updated;
}
@@ -2,7 +2,7 @@ import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
export const getRecurringTasks = asyncHandler(async (req, res) => {
ok(res, await service.listRecurringTasks());
ok(res, await service.listRecurringTasks(req.currentUser));
});
export const postRecurringTask = asyncHandler(async (req, res) => {
@@ -5,7 +5,7 @@ const LIST_SELECT = `
r.responsible_user_id, u.name AS responsible_name,
r.interval_unit, r.interval_value, r.next_due_date, r.end_date,
r.reminder_days_before, r.reminder_hour, r.is_active, r.last_reminder_sent_at,
r.created_by, r.created_at, r.updated_at
r.created_by, r.edit_version, r.created_at, r.updated_at
FROM recurring_tasks r
LEFT JOIN projects p ON p.id = r.project_id
LEFT JOIN users u ON u.id = r.responsible_user_id
@@ -38,19 +38,19 @@ export async function create(data) {
}
export async function update(id, data) {
await pool.query(
const [result] = await pool.query(
`UPDATE recurring_tasks SET
name = ?, description = ?, project_id = ?, responsible_user_id = ?, interval_unit = ?,
interval_value = ?, next_due_date = ?, end_date = ?, reminder_days_before = ?, reminder_hour = ?, is_active = ?
WHERE id = ?`,
interval_value = ?, next_due_date = ?, end_date = ?, reminder_days_before = ?, reminder_hour = ?, is_active = ?, edit_version = edit_version + 1
WHERE id = ? AND edit_version = ?`,
[
data.name, data.description || null, data.projectId || null, data.responsibleUserId || null,
data.intervalUnit, data.intervalValue, data.nextDueDate, data.endDate || null,
data.reminderDaysBefore ?? 7, data.reminderHour ?? 9,
data.isActive === false ? 0 : 1, id,
data.isActive === false ? 0 : 1, id, data.editVersion,
]
);
return findById(id);
return result.affectedRows ? findById(id) : null;
}
export async function remove(id) {
@@ -69,12 +69,12 @@ export async function advanceCycle(id) {
const unit = SQL_UNIT[row.interval_unit] || 'MONTH';
await pool.query(
`UPDATE recurring_tasks
SET next_due_date = DATE_ADD(next_due_date, INTERVAL ? ${unit}), last_reminder_sent_at = NULL
SET next_due_date = DATE_ADD(next_due_date, INTERVAL ? ${unit}), last_reminder_sent_at = NULL, edit_version = edit_version + 1
WHERE id = ?`,
[row.interval_value, id]
);
await pool.query(
`UPDATE recurring_tasks SET is_active = 0
`UPDATE recurring_tasks SET is_active = 0, edit_version = edit_version + 1
WHERE id = ? AND end_date IS NOT NULL AND next_due_date > end_date`,
[id]
);
+45 -7
View File
@@ -2,8 +2,12 @@ import * as repo from './repository.js';
import * as tasksRepo from '../tasks/repository.js';
import * as activitiesRepo from '../activities/repository.js';
import { HttpError } from '../../utils/http.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
import { logAudit } from '../../utils/audit.js';
import { sendMail } from '../../utils/mailer.js';
import { canReadProject, canWriteProject } from '../projects/service.js';
import * as projectsRepo from '../projects/repository.js';
import { escapeHtml } from '../../utils/emailHtml.js';
const INTERVAL_UNITS = ['days', 'months', 'years'];
@@ -20,9 +24,33 @@ function validate(payload) {
}
}
export const listRecurringTasks = () => repo.findAll();
const FULL = ['superadmin', 'admin', 'it_manager'];
function canOwn(user, task) { return FULL.includes(user.roleCode) || task.created_by === user.id; }
async function checkProject(projectId, user, write) {
if (!projectId) return;
const project = await projectsRepo.findById(projectId);
if (!project || !(await (write ? canWriteProject : canReadProject)(user, project))) throw new HttpError(404, 'Proyecto no encontrado');
}
function checkWritable(user, task) {
if (user.roleCode === 'viewer' || (task && !canOwn(user, task))) throw new HttpError(404, 'Tarea recurrente no encontrada');
}
export async function listRecurringTasks(user) {
const tasks = await repo.findAll();
const result = [];
for (const task of tasks) {
if (!canOwn(user, task)) continue;
if (task.project_id) {
const project = await projectsRepo.findById(task.project_id);
if (!project || !(await canReadProject(user, project))) continue;
}
result.push(task);
}
return result;
}
export async function createRecurringTask(payload, currentUser) {
checkWritable(currentUser);
await checkProject(payload.projectId, currentUser, true);
validate(payload);
const task = await repo.create({ ...payload, createdBy: currentUser.id });
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'recurring_task', entityId: task.id });
@@ -32,8 +60,12 @@ export async function createRecurringTask(payload, currentUser) {
export async function updateRecurringTask(id, payload, currentUser) {
const existing = await repo.findById(id);
if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada');
checkWritable(currentUser, existing);
await checkProject(existing.project_id, currentUser, true);
await checkProject(payload.projectId, currentUser, true);
validate(payload);
const task = await repo.update(id, payload);
requireEditVersion(payload.editVersion);
const task = requireUpdated(await repo.update(id, payload));
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'recurring_task', entityId: id });
return task;
}
@@ -41,6 +73,8 @@ export async function updateRecurringTask(id, payload, currentUser) {
export async function removeRecurringTask(id, currentUser) {
const existing = await repo.findById(id);
if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada');
checkWritable(currentUser, existing);
await checkProject(existing.project_id, currentUser, true);
await repo.remove(id);
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'recurring_task', entityId: id });
}
@@ -50,6 +84,8 @@ export async function removeRecurringTask(id, currentUser) {
export async function completeCycle(id, currentUser) {
const existing = await repo.findById(id);
if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada');
checkWritable(currentUser, existing);
await checkProject(existing.project_id, currentUser, true);
const task = await repo.advanceCycle(id);
await logAudit({ userId: currentUser.id, action: 'complete_cycle', entityType: 'recurring_task', entityId: id });
return task;
@@ -59,10 +95,12 @@ export async function completeCycle(id, currentUser) {
// actividad NO se borra (queda como referencia de que ya se hizo esta vez), simplemente se crea
// la tarea recurrente enlazada al proyecto de esa actividad para tener contexto.
export async function promoteFromTask(taskId, payload, currentUser) {
checkWritable(currentUser);
const sourceTask = await tasksRepo.findById(taskId);
if (!sourceTask) throw new HttpError(404, 'Tarea no encontrada');
const activity = await activitiesRepo.findById(sourceTask.activity_id);
if (!activity) throw new HttpError(404, 'Actividad no encontrada');
await checkProject(activity.project_id, currentUser, true);
const merged = {
name: payload.name || sourceTask.name,
@@ -94,18 +132,18 @@ export async function sendDueReminders() {
const to = item.responsible_email || item.creator_email;
const recipientName = item.responsible_name || item.creator_name;
try {
await sendMail({
const result = await sendMail({
to,
subject: `Recordatorio: ${item.name} vence el ${item.next_due_date}`,
html: `<p>Hola ${recipientName},</p>
<p>La tarea recurrente <strong>${item.name}</strong>${item.project_name ? ` (proyecto: ${item.project_name})` : ''}
vence el <strong>${item.next_due_date}</strong>.</p>
html: `<p>Hola ${escapeHtml(recipientName)},</p>
<p>La tarea recurrente <strong>${escapeHtml(item.name)}</strong>${item.project_name ? ` (proyecto: ${escapeHtml(item.project_name)})` : ''}
vence el <strong>${escapeHtml(item.next_due_date)}</strong>.</p>
<p>Entra a project.linkedcorp.net → Tareas recurrentes para revisarla.</p>`,
});
if (result.sent) await repo.markReminderSent(item.id);
} catch (err) {
console.error('[recurring-tasks] error enviando recordatorio', item.id, err.message);
}
await repo.markReminderSent(item.id);
}
return due.length;
}
+1 -1
View File
@@ -2,5 +2,5 @@ import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
export const getSearch = asyncHandler(async (req, res) => {
ok(res, await service.search(req.query.q, req.query.projectId));
ok(res, await service.search(req.query.q, req.query.projectId, req.currentUser));
});
+13 -10
View File
@@ -4,14 +4,17 @@ const RESULT_LIMIT = 20;
// Busqueda global (menu Proyectos): proyectos, actividades (con su proyecto) y tareas (con su
// actividad y proyecto) — para armar la migaja de pan "Proyecto - Actividad - Tarea" en el cliente.
export async function searchGlobal(term) {
export async function searchGlobal(term, user) {
const like = `%${term}%`;
const fullAccess = ['superadmin', 'admin', 'it_manager'].includes(user.roleCode);
const scope = fullAccess ? '' : ' AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)';
const scopeParams = fullAccess ? [] : [user.id];
const [projects] = await pool.query(
`SELECT id, code, name FROM projects
WHERE deleted_at IS NULL AND (code LIKE ? OR name LIKE ?)
`SELECT p.id, p.code, p.name FROM projects p
WHERE p.deleted_at IS NULL AND (p.code LIKE ? OR p.name LIKE ?)${scope}
ORDER BY name LIMIT ?`,
[like, like, RESULT_LIMIT]
[like, like, ...scopeParams, RESULT_LIMIT]
);
const [activities] = await pool.query(
@@ -19,9 +22,9 @@ export async function searchGlobal(term) {
p.id AS project_id, p.code AS project_code, p.name AS project_name
FROM activities a
JOIN projects p ON p.id = a.project_id
WHERE p.deleted_at IS NULL AND (a.code LIKE ? OR a.name LIKE ?)
WHERE p.deleted_at IS NULL AND a.deleted_at IS NULL AND (a.code LIKE ? OR a.name LIKE ?)${scope}
ORDER BY a.name LIMIT ?`,
[like, like, RESULT_LIMIT]
[like, like, ...scopeParams, RESULT_LIMIT]
);
const [tasks] = await pool.query(
@@ -31,9 +34,9 @@ export async function searchGlobal(term) {
FROM tasks t
JOIN activities a ON a.id = t.activity_id
JOIN projects p ON p.id = a.project_id
WHERE p.deleted_at IS NULL AND t.name LIKE ?
WHERE p.deleted_at IS NULL AND a.deleted_at IS NULL AND t.deleted_at IS NULL AND t.name LIKE ?${scope}
ORDER BY t.name LIMIT ?`,
[like, RESULT_LIMIT]
[like, ...scopeParams, RESULT_LIMIT]
);
return { projects, activities, tasks };
@@ -46,7 +49,7 @@ export async function searchInProject(projectId, term) {
const [activities] = await pool.query(
`SELECT id, code, name FROM activities
WHERE project_id = ? AND (code LIKE ? OR name LIKE ?)
WHERE project_id = ? AND deleted_at IS NULL AND (code LIKE ? OR name LIKE ?)
ORDER BY name LIMIT ?`,
[projectId, like, like, RESULT_LIMIT]
);
@@ -55,7 +58,7 @@ export async function searchInProject(projectId, term) {
`SELECT t.id, t.name, a.id AS activity_id, a.code AS activity_code, a.name AS activity_name
FROM tasks t
JOIN activities a ON a.id = t.activity_id
WHERE a.project_id = ? AND t.name LIKE ?
WHERE a.project_id = ? AND a.deleted_at IS NULL AND t.deleted_at IS NULL AND t.name LIKE ?
ORDER BY t.name LIMIT ?`,
[projectId, like, RESULT_LIMIT]
);
+8 -2
View File
@@ -1,11 +1,17 @@
import * as repo from './repository.js';
import { getProject } from '../projects/service.js';
import { HttpError } from '../../utils/http.js';
const MIN_LENGTH = 2;
export async function search(term, projectId) {
export async function search(term, projectId, currentUser) {
const q = (term || '').trim();
if (projectId) {
if (!Number.isSafeInteger(Number(projectId)) || Number(projectId) < 1) throw new HttpError(400, 'projectId invalido');
await getProject(Number(projectId), currentUser);
}
if (q.length < MIN_LENGTH) {
return projectId ? { activities: [], tasks: [] } : { projects: [], activities: [], tasks: [] };
}
return projectId ? repo.searchInProject(Number(projectId), q) : repo.searchGlobal(q);
return projectId ? repo.searchInProject(Number(projectId), q) : repo.searchGlobal(q, currentUser);
}
@@ -16,3 +16,27 @@ export async function setMany(entries) {
);
}
}
// La fila de version serializa las escrituras de cada panel y los valores se cambian
// en la misma transaccion. Una version antigua no modifica ninguna clave.
export async function setManyIfVersion(entries, versionKey, editVersion) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [result] = await conn.query(
'UPDATE system_settings SET `value` = CAST(CAST(`value` AS UNSIGNED) + 1 AS CHAR) WHERE `key` = ? AND CAST(`value` AS UNSIGNED) = ?',
[versionKey, editVersion]
);
if (!result.affectedRows) { await conn.rollback(); return false; }
for (const [key, value] of Object.entries(entries)) {
await conn.query('INSERT INTO system_settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)', [key, value]);
}
await conn.commit();
return true;
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}
+5 -2
View File
@@ -2,6 +2,7 @@ import nodemailer from 'nodemailer';
import * as repo from './repository.js';
import { encrypt, decrypt } from '../../utils/crypto.js';
import { logAudit } from '../../utils/audit.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
import { HttpError } from '../../utils/http.js';
const SMTP_KEYS = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'smtp_pass_enc'];
@@ -12,8 +13,9 @@ const SMTP_KEYS = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'smtp_pas
const stripSpaces = (s) => (s || '').replace(/\s+/g, '');
export async function getSmtpConfig() {
const values = await repo.getAll(SMTP_KEYS);
const values = await repo.getAll([...SMTP_KEYS, 'smtp_edit_version']);
return {
edit_version: Number(values.smtp_edit_version),
host: values.smtp_host || '',
port: values.smtp_port ? Number(values.smtp_port) : 587,
user: values.smtp_user || '',
@@ -46,6 +48,7 @@ export async function getSmtpConfigInternal() {
}
export async function updateSmtpConfig(payload, actorId) {
requireEditVersion(payload.editVersion);
const host = stripSpaces(payload.host);
const user = stripSpaces(payload.user);
const from = stripSpaces(payload.from);
@@ -57,7 +60,7 @@ export async function updateSmtpConfig(payload, actorId) {
smtp_from: from || user,
};
if (payload.pass) entries.smtp_pass_enc = encrypt(payload.pass);
await repo.setMany(entries);
requireUpdated(await repo.setManyIfVersion(entries, 'smtp_edit_version', payload.editVersion));
await logAudit({ userId: actorId, action: 'update', entityType: 'settings:smtp', entityId: null });
return getSmtpConfig();
}
+9 -9
View File
@@ -2,7 +2,7 @@ import { pool } from '../../config/db.js';
const LIST_SELECT = `
SELECT t.id, t.activity_id, t.name, t.responsible_user_id, u.name AS responsible_name,
t.is_done, t.due_date, t.sort_order, t.created_at
t.is_done, t.due_date, t.edit_version, t.sort_order, t.created_at
FROM tasks t LEFT JOIN users u ON u.id = t.responsible_user_id
`;
@@ -44,30 +44,30 @@ export async function create(activityId, { name, responsibleUserId, dueDate }) {
return findById(result.insertId);
}
export async function update(id, { name, responsibleUserId, dueDate }) {
await pool.query('UPDATE tasks SET name = ?, responsible_user_id = ?, due_date = ? WHERE id = ?', [
name, responsibleUserId || null, dueDate || null, id,
export async function update(id, { name, responsibleUserId, dueDate, editVersion }) {
const [result] = await pool.query('UPDATE tasks SET name = ?, responsible_user_id = ?, due_date = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ? AND deleted_at IS NULL', [
name, responsibleUserId || null, dueDate || null, id, editVersion,
]);
return findById(id);
return result.affectedRows ? findById(id) : null;
}
export async function setDone(id, isDone) {
await pool.query('UPDATE tasks SET is_done = ? WHERE id = ?', [isDone ? 1 : 0, id]);
await pool.query('UPDATE tasks SET is_done = ?, edit_version = edit_version + 1 WHERE id = ?', [isDone ? 1 : 0, id]);
return findById(id);
}
export async function softDelete(id, userId) {
await pool.query('UPDATE tasks SET deleted_at = NOW(), deleted_by = ? WHERE id = ?', [userId, id]);
await pool.query('UPDATE tasks SET deleted_at = NOW(), deleted_by = ?, edit_version = edit_version + 1 WHERE id = ?', [userId, id]);
}
export async function restore(id) {
await pool.query('UPDATE tasks SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', [id]);
await pool.query('UPDATE tasks SET deleted_at = NULL, deleted_by = NULL, edit_version = edit_version + 1 WHERE id = ?', [id]);
}
// Usado al convertir una actividad en proyecto: sus tareas pasan a la actividad nueva que se crea
// para contenerlas. sort_order vuelve a 0 ("nunca reordenado a mano") en el destino.
export async function moveAll(oldActivityId, newActivityId) {
await pool.query('UPDATE tasks SET activity_id = ?, sort_order = 0 WHERE activity_id = ?', [newActivityId, oldActivityId]);
await pool.query('UPDATE tasks SET activity_id = ?, sort_order = 0, edit_version = edit_version + 1 WHERE activity_id = ?', [newActivityId, oldActivityId]);
}
export async function countsByActivity(activityId) {
+3 -1
View File
@@ -4,6 +4,7 @@ import * as projectsRepo from '../projects/repository.js';
import { canWriteActivity, recalcFromTasks } from '../activities/service.js';
import { canReadProject } from '../projects/service.js';
import { HttpError } from '../../utils/http.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
import { logAudit } from '../../utils/audit.js';
async function requireActivityAccess(activityId, currentUser) {
@@ -44,7 +45,8 @@ export async function updateTask(id, payload, currentUser) {
if (!task) throw new HttpError(404, 'Tarea no encontrada');
await requireActivityAccess(task.activity_id, currentUser);
if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio');
const updated = await repo.update(id, payload);
requireEditVersion(payload.editVersion);
const updated = requireUpdated(await repo.update(id, payload));
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'task', entityId: id });
return updated;
}
+4 -4
View File
@@ -5,22 +5,22 @@ export const getUsers = asyncHandler(async (req, res) => ok(res, await service.l
export const getRoles = asyncHandler(async (req, res) => ok(res, await service.listRoles()));
export const postUser = asyncHandler(async (req, res) => {
const user = await service.createUser(req.body, req.currentUser.id);
const user = await service.createUser(req.body, req.currentUser);
ok(res, user, 201);
});
export const putUser = asyncHandler(async (req, res) => {
const user = await service.updateUser(Number(req.params.id), req.body, req.currentUser.id);
const user = await service.updateUser(Number(req.params.id), req.body, req.currentUser);
ok(res, user);
});
export const patchUserActive = asyncHandler(async (req, res) => {
const user = await service.setUserActive(Number(req.params.id), !!req.body.isActive, req.currentUser.id);
const user = await service.setUserActive(Number(req.params.id), !!req.body.isActive, req.currentUser);
ok(res, user);
});
export const postApproveUser = asyncHandler(async (req, res) => {
const user = await service.approveUser(Number(req.params.id), req.body, req.currentUser.id);
const user = await service.approveUser(Number(req.params.id), req.body, req.currentUser);
ok(res, user);
});
+14 -14
View File
@@ -3,7 +3,7 @@ import { pool } from '../../config/db.js';
export async function findAll() {
const [rows] = await pool.query(
`SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, r.name AS role_name,
u.department_id, d.name AS department_name, u.position, u.is_active, u.status, u.created_at
u.department_id, d.name AS department_name, u.position, u.is_active, u.status, u.edit_version, u.created_at
FROM users u
JOIN roles r ON r.id = u.role_id
LEFT JOIN departments d ON d.id = u.department_id
@@ -14,7 +14,7 @@ export async function findAll() {
export async function findById(id) {
const [rows] = await pool.query(
`SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, u.department_id, u.position, u.is_active, u.status
`SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, u.department_id, u.position, u.is_active, u.status, u.edit_version
FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`,
[id]
);
@@ -51,26 +51,26 @@ export async function create({ name, email, passwordHash, roleId, departmentId,
return findById(result.insertId);
}
export async function update(id, { name, roleId, departmentId, position }) {
await pool.query(
`UPDATE users SET name = ?, role_id = ?, department_id = ?, position = ? WHERE id = ?`,
[name, roleId, departmentId || null, position || null, id]
export async function update(id, { name, roleId, departmentId, position, editVersion }) {
const [result] = await pool.query(
`UPDATE users SET name = ?, role_id = ?, department_id = ?, position = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?`,
[name, roleId, departmentId || null, position || null, id, editVersion]
);
return findById(id);
return result.affectedRows ? findById(id) : null;
}
export async function setActive(id, isActive) {
await pool.query('UPDATE users SET is_active = ? WHERE id = ?', [isActive ? 1 : 0, id]);
await pool.query('UPDATE users SET is_active = ?, edit_version = edit_version + 1 WHERE id = ?', [isActive ? 1 : 0, id]);
return findById(id);
}
export async function approve(id, { roleId, departmentId }) {
await pool.query(
`UPDATE users SET status = 'active', is_active = 1, role_id = COALESCE(?, role_id), department_id = COALESCE(?, department_id)
WHERE id = ?`,
[roleId || null, departmentId || null, id]
export async function approve(id, { roleId, departmentId, editVersion }) {
const [result] = await pool.query(
`UPDATE users SET status = 'active', is_active = 1, edit_version = edit_version + 1, role_id = COALESCE(?, role_id), department_id = COALESCE(?, department_id)
WHERE id = ? AND edit_version = ? AND status = 'pending_approval'`,
[roleId || null, departmentId || null, id, editVersion]
);
return findById(id);
return result.affectedRows ? findById(id) : null;
}
export async function remove(id) {
+36 -17
View File
@@ -2,9 +2,11 @@ import crypto from 'node:crypto';
import bcrypt from 'bcryptjs';
import * as repo from './repository.js';
import { HttpError } from '../../utils/http.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
import { logAudit } from '../../utils/audit.js';
import { sendMail } from '../../utils/mailer.js';
import { env } from '../../config/env.js';
import { invitationEmail, verificationEmail } from '../../utils/emailHtml.js';
export const listUsers = () => repo.findAll();
export const listRoles = () => repo.listRoles();
@@ -28,41 +30,56 @@ export async function inviteUser(email, actorId) {
await sendMail({
to: email,
subject: 'Te invitaron a project.linkedcorp.net',
html: `<p>Te agregaron como miembro de un proyecto en project.linkedcorp.net.</p>
<p>Completa tu registro para acceder (elige tu nombre y una clave):</p>
<p><a href="${inviteUrl}">${inviteUrl}</a></p><p>Este enlace vence en 7 dias.</p>`,
html: invitationEmail(inviteUrl),
});
return { userId, invited: true };
}
export async function createUser(payload, actorId) {
async function checkRole(roleId, actor) {
const role = (await repo.listRoles()).find((item) => item.id === Number(roleId));
if (!role) throw new HttpError(400, 'Rol invalido');
if (role.code === 'superadmin' && actor.roleCode !== 'superadmin') throw new HttpError(403, 'No puedes asignar ese rol');
}
function checkTarget(target, actor) {
if (target.role_code === 'superadmin' && actor.roleCode !== 'superadmin') {
throw new HttpError(403, 'No puedes modificar un superadministrador');
}
}
export async function createUser(payload, actor) {
if (!payload.name || !payload.email || !payload.password || !payload.roleId) {
throw new HttpError(400, 'Nombre, email, clave y rol son obligatorios');
}
const existing = await repo.findByEmail(payload.email);
if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email');
await checkRole(payload.roleId, actor);
const passwordHash = await bcrypt.hash(payload.password, 12);
const user = await repo.create({ ...payload, passwordHash });
await logAudit({ userId: actorId, action: 'create', entityType: 'user', entityId: user.id });
await logAudit({ userId: actor.id, action: 'create', entityType: 'user', entityId: user.id });
return user;
}
export async function updateUser(id, payload, actorId) {
export async function updateUser(id, payload, actor) {
const existing = await repo.findById(id);
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
const user = await repo.update(id, payload);
await logAudit({ userId: actorId, action: 'update', entityType: 'user', entityId: id });
checkTarget(existing, actor);
await checkRole(payload.roleId, actor);
requireEditVersion(payload.editVersion);
const user = requireUpdated(await repo.update(id, payload));
await logAudit({ userId: actor.id, action: 'update', entityType: 'user', entityId: id });
return user;
}
export async function setUserActive(id, isActive, actorId) {
export async function setUserActive(id, isActive, actor) {
const existing = await repo.findById(id);
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
checkTarget(existing, actor);
const user = await repo.setActive(id, isActive);
await logAudit({
userId: actorId,
userId: actor.id,
action: isActive ? 'activate' : 'deactivate',
entityType: 'user',
entityId: id,
@@ -70,12 +87,16 @@ export async function setUserActive(id, isActive, actorId) {
return user;
}
export async function approveUser(id, payload, actorId) {
export async function approveUser(id, payload, actor) {
const existing = await repo.findById(id);
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
checkTarget(existing, actor);
requireEditVersion(payload.editVersion);
if (existing.edit_version !== payload.editVersion) requireUpdated(null);
if (existing.status !== 'pending_approval') throw new HttpError(400, 'El usuario no esta pendiente de aprobacion');
const user = await repo.approve(id, payload);
await logAudit({ userId: actorId, action: 'approve', entityType: 'user', entityId: id });
if (payload.roleId) await checkRole(payload.roleId, actor);
const user = requireUpdated(await repo.approve(id, payload));
await logAudit({ userId: actor.id, action: 'approve', entityType: 'user', entityId: id });
return user;
}
@@ -99,9 +120,7 @@ export async function resendUserEmail(id, actorId) {
await sendMail({
to: existing.email,
subject: 'Te invitaron a project.linkedcorp.net',
html: `<p>Te agregaron como miembro de un proyecto en project.linkedcorp.net.</p>
<p>Completa tu registro para acceder (elige tu nombre y una clave):</p>
<p><a href="${inviteUrl}">${inviteUrl}</a></p><p>Este enlace vence en 7 dias.</p>`,
html: invitationEmail(inviteUrl),
});
} else {
await repo.refreshVerificationToken(id, token, 24);
@@ -109,7 +128,7 @@ export async function resendUserEmail(id, actorId) {
await sendMail({
to: existing.email,
subject: 'Confirma tu correo — project.linkedcorp.net',
html: `<p>Hola ${existing.name},</p><p>Confirma tu correo para continuar tu registro:</p><p><a href="${verifyUrl}">${verifyUrl}</a></p><p>Este enlace vence en 24 horas.</p>`,
html: verificationEmail(existing.name, verifyUrl),
});
}
+3 -2
View File
@@ -8,6 +8,7 @@ import { encrypt, decrypt } from '../../utils/crypto.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { sendMail } from '../../utils/mailer.js';
import { escapeHtml } from '../../utils/emailHtml.js';
const PIN_RE = /^\d{4}$/;
const VAULT_TOKEN_EXPIRES = '30m';
@@ -272,11 +273,11 @@ export async function forgotPin(currentUser) {
await sendMail({
to: currentUser.email,
subject: 'Restablecer PIN de Contraseñas — project.linkedcorp.net',
html: `<p>Hola ${currentUser.name},</p>
html: `<p>Hola ${escapeHtml(currentUser.name)},</p>
<p>Pediste restablecer el PIN de acceso a tu repositorio de Contraseñas. Esto solo cambia
el PIN -- tus contraseñas guardadas no se tocan ni se pierden.</p>
<p>Elegi un PIN nuevo en este enlace (vence en 1 hora):</p>
<p><a href="${resetUrl}">${resetUrl}</a></p>
<p><a href="${escapeHtml(resetUrl)}">${escapeHtml(resetUrl)}</a></p>
<p>Si no pediste esto, ignora el correo.</p>`,
});
}
+1 -1
View File
@@ -30,7 +30,7 @@ async function run() {
await db.query(
`INSERT INTO users (name, email, password_hash, role_id, department_id, position, is_active)
VALUES (?, ?, ?, ?, NULL, NULL, 1)
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), is_active = 1`,
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), session_version = session_version + 1, is_active = 1`,
[name, email, passwordHash, adminRole[0].id]
);
+11
View File
@@ -0,0 +1,11 @@
import { HttpError } from './http.js';
export function requireEditVersion(value) {
if (!Number.isSafeInteger(value) || value < 1) throw new HttpError(428, 'Falta la version de edicion');
return value;
}
export function requireUpdated(row) {
if (!row) throw new HttpError(409, 'El registro cambio en el servidor. Recarga antes de guardar');
return row;
}
+20
View File
@@ -0,0 +1,20 @@
export function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
}
export function invitationEmail(url) {
const safeUrl = escapeHtml(url);
return `<p>Te agregaron como miembro de un proyecto en project.linkedcorp.net.</p>
<p>Completa tu registro para acceder (elige tu nombre y una clave):</p>
<p><a href="${safeUrl}">${safeUrl}</a></p><p>Este enlace vence en 7 dias.</p>`;
}
export function verificationEmail(name, url) {
const safeUrl = escapeHtml(url);
return `<p>Hola ${escapeHtml(name)},</p><p>Confirma tu correo para continuar tu registro:</p><p><a href="${safeUrl}">${safeUrl}</a></p><p>Este enlace vence en 24 horas.</p>`;
}
export function recoveryEmail(name, url) {
const safeUrl = escapeHtml(url);
return `<p>Hola ${escapeHtml(name)},</p><p>Para elegir una nueva clave entra a este enlace (vence en 1 hora):</p><p><a href="${safeUrl}">${safeUrl}</a></p><p>Si no pediste esto, ignora el correo.</p>`;
}
+13 -13
View File
@@ -4,24 +4,24 @@ import { getSmtpConfigInternal } from '../modules/settings/service.js';
// Degradacion a modo dev cuando el SMTP no esta configurado (regla del orquestador): en vez de
// fallar, se loguea el contenido/enlace y el flujo que llamo a esto sigue funcionando.
export async function sendMail({ to, subject, html }) {
const config = await getSmtpConfigInternal();
try {
const config = await getSmtpConfigInternal();
if (!config) {
console.log(`[mail:dev] to=${to} subject="${subject}"\n${html}`);
return { sent: false, dev: true };
}
if (!config) {
console.log(`[mail:dev] to=${to} subject="${subject}"\n${html}`);
return { sent: false, dev: true };
}
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: false,
requireTLS: true,
auth: { user: config.user, pass: config.pass },
});
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: false,
requireTLS: true,
auth: { user: config.user, pass: config.pass },
});
// Nunca dejar que un fallo de envio (SMTP mal configurado, host caido) tumbe al que llama —
// critico para forgot-password, que siempre debe responder generico sin importar el resultado.
try {
await transporter.sendMail({ from: config.from, to, subject, html });
return { sent: true, dev: false };
} catch (err) {
+12
View File
@@ -0,0 +1,12 @@
export function redactUrl(url = '') {
const value = String(url);
try {
const parsed = new URL(value, 'http://local.invalid');
for (const key of parsed.searchParams.keys()) {
if (key.toLowerCase() === 'token') parsed.searchParams.set(key, '[REDACTED]');
}
return parsed.pathname + parsed.search + parsed.hash;
} catch {
return value.replace(/([?&]token=)[^&#]*/gi, '$1[REDACTED]');
}
}