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:
@@ -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`;
|
||||
Generated
+2
-2
@@ -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,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
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
@@ -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) {
|
||||
|
||||
@@ -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]');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user