Files
CATALOGO_INFINITY/backend/server.js
T

1490 lines
53 KiB
JavaScript

/* ════════════════════════════════
INFINITY — BACKEND
Express + sesiones + JSON storage
════════════════════════════════ */
const express = require('express');
const cookieParser = require('cookie-parser');
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const crypto = require('crypto');
const multer = require('multer');
const sharp = require('sharp');
const XLSX = require('xlsx');
const app = express();
const PORT = process.env.PORT || 3000;
const SECRET = process.env.SESSION_SECRET || 'infinity-cambia-esta-clave-en-produccion';
const DATA_DIR = path.join(__dirname, 'data');
const FRONTEND_DIR = path.join(__dirname, 'frontend');
const IMG_DIR = path.join(__dirname, 'Imagenes');
// ════════════════════════════════
// MIDDLEWARES
// ════════════════════════════════
app.use(express.json());
app.use(cookieParser(SECRET));
app.use(express.static(FRONTEND_DIR));
app.use('/Imagenes', express.static(IMG_DIR, { maxAge: '7d' }));
// Evita el 404 de favicon.ico
app.get('/favicon.ico', (req, res) => res.status(204).end());
// ════════════════════════════════
// HELPERS
// ════════════════════════════════
async function readJson(filename) {
const filePath = path.join(DATA_DIR, filename);
const data = await fs.readFile(filePath, 'utf8');
return JSON.parse(data);
}
async function writeJson(filename, data) {
const filePath = path.join(DATA_DIR, filename);
await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
}
async function ensureSesiones() {
try {
await fs.access(path.join(DATA_DIR, 'sesiones.json'));
} catch {
await writeJson('sesiones.json', []);
}
}
async function getSesiones() {
await ensureSesiones();
return await readJson('sesiones.json');
}
async function saveSesiones(sesiones) {
await writeJson('sesiones.json', sesiones);
}
async function createSession(usuario) {
const sesiones = await getSesiones();
const token = crypto.randomBytes(32).toString('hex');
sesiones.push({
token,
usuario: usuario.usuario,
rol: usuario.rol,
createdAt: Date.now()
});
await saveSesiones(sesiones);
return token;
}
async function destroySession(token) {
const sesiones = await getSesiones();
const filtradas = sesiones.filter(s => s.token !== token);
await saveSesiones(filtradas);
}
async function getSessionUser(req) {
const token = req.signedCookies.session;
if (!token) return null;
const sesiones = await getSesiones();
return sesiones.find(s => s.token === token) || null;
}
async function getUserData(usuario) {
try {
const data = await readJson('contraseñas.json');
return (data.usuarios || []).find(u => u.usuario === usuario) || null;
} catch {
return null;
}
}
// ════════════════════════════════
// HELPERS — Categorías
// ════════════════════════════════
const SIN_CATEGORIA_ID = 'sin-categoria';
function slugify(text) {
return String(text)
.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // quitar acentos
.replace(/[^a-z0-9\s-]/g, '') // quitar chars especiales
.trim()
.replace(/\s+/g, '-') // espacios → guiones
.replace(/-+/g, '-') // colapsar guiones
.replace(/^-+|-+$/g, ''); // trim guiones
}
async function getCategorias() {
try {
return await readJson('categorias.json');
} catch {
return [];
}
}
async function saveCategorias(categorias) {
await writeJson('categorias.json', categorias);
}
// ════════════════════════════════
// TREE HELPERS — para subcategorías anidadas
// Estructura: cada categoría puede tener un array opcional `subcategorias`
// con la misma forma, recursivamente. Máximo 3 niveles (cat > sub > sub-sub).
// ════════════════════════════════
function findCategoryById(categorias, id) {
for (const c of categorias) {
if (c.id === id) return c;
if (Array.isArray(c.subcategorias) && c.subcategorias.length) {
const found = findInSubcats(c.subcategorias, id);
if (found) return found;
}
}
return null;
}
function findInSubcats(subcats, id) {
for (const s of subcats) {
if (s.id === id) return s;
if (Array.isArray(s.subcategorias) && s.subcategorias.length) {
const found = findInSubcats(s.subcategorias, id);
if (found) return found;
}
}
return null;
}
// Devuelve la ruta desde la raíz hasta el nodo con ese id.
// Ej: para "morrales" en bolsos>hombre>morrales → [{id:bolsos}, {id:hombre}, {id:morrales}]
function getCategoryPath(categorias, id, trail = []) {
for (const c of categorias) {
const next = [...trail, c];
if (c.id === id) return next;
if (Array.isArray(c.subcategorias) && c.subcategorias.length) {
const found = getCategoryPath(c.subcategorias, id, next);
if (found) return found;
}
}
return null;
}
// Recolecta todos los ids de subcategorías dentro de un array de subcats.
// Usado para validar duplicados al crear una nueva subcategoría.
function collectSubcatIds(subcats, acc = new Set()) {
if (!Array.isArray(subcats)) return acc;
for (const s of subcats) {
if (s.id) acc.add(s.id);
if (Array.isArray(s.subcategorias)) collectSubcatIds(s.subcategorias, acc);
}
return acc;
}
// Quita una subcategoría del árbol (modifica in place). Devuelve true si la encontró.
function removeSubcatFromTree(categorias, id) {
for (const c of categorias) {
if (Array.isArray(c.subcategorias)) {
const idx = c.subcategorias.findIndex(s => s.id === id);
if (idx !== -1) {
c.subcategorias.splice(idx, 1);
return true;
}
if (removeSubcatFromTree(c.subcategorias, id)) return true;
}
}
return false;
}
// Busca una subcat por id en un array de subcats (cualquier nivel) y la devuelve, o null.
function findSubcatRecursive(subcats, id) {
if (!Array.isArray(subcats)) return null;
for (const s of subcats) {
if (s.id === id) return s;
if (Array.isArray(s.subcategorias)) {
const found = findSubcatRecursive(s.subcategorias, id);
if (found) return found;
}
}
return null;
}
// Quita una subcat de un array de subcats (cualquier nivel) y la devuelve. O null si no está.
function removeSubcatRecursive(subcats, id) {
if (!Array.isArray(subcats)) return null;
for (let i = 0; i < subcats.length; i++) {
if (subcats[i].id === id) {
const [removed] = subcats.splice(i, 1);
return removed;
}
if (Array.isArray(subcats[i].subcategorias)) {
const found = removeSubcatRecursive(subcats[i].subcategorias, id);
if (found) return found;
}
}
return null;
}
async function ensureSinCategoria() {
const categorias = await getCategorias();
if (!categorias.some(c => c.id === SIN_CATEGORIA_ID)) {
categorias.push({ id: SIN_CATEGORIA_ID, nombre: 'Sin categoría', icono: '📦' });
await saveCategorias(categorias);
}
// Asegurar carpeta física
const dir = path.join(IMG_DIR, SIN_CATEGORIA_ID);
try {
await fs.access(dir);
} catch {
await fs.mkdir(dir, { recursive: true });
}
}
async function getProductos() {
try {
return await readJson('productos.json');
} catch {
return [];
}
}
async function saveProductos(productos) {
await writeJson('productos.json', productos);
}
// Helper: mover la imagen de un producto entre carpetas.
// - oldImg: ruta actual relativa a /Imagenes/ (ej: "bolsos/AF100-124.webp")
// - newCat: id de la categoría destino (o "agotados")
// - ref: referencia del producto (la imagen destino será "<ref>.webp")
// Devuelve la nueva ruta relativa ("<newCat>/<ref>.webp").
// Si la imagen origen no existe, no falla: igual devuelve la nueva ruta
// (útil para entradas huérfanas en el JSON).
async function moveProductImage(oldImg, newCat, ref) {
const newName = `${ref}.webp`;
const newPath = path.join(IMG_DIR, newCat, newName);
// Crear carpeta destino si no existe
await fs.mkdir(path.join(IMG_DIR, newCat), { recursive: true });
if (oldImg) {
const oldPath = path.join(IMG_DIR, oldImg);
try {
await fs.rename(oldPath, newPath);
} catch (err) {
if (err.code === 'ENOENT') {
// Imagen origen no existe: no es error (entrada huérfana).
// Igual devolvemos la nueva ruta; el JSON quedará coherente.
} else {
// Cross-device u otro error: copiar y borrar.
try {
await fs.copyFile(oldPath, newPath);
await fs.unlink(oldPath);
} catch (err2) {
if (err2.code !== 'ENOENT') throw err2;
}
}
}
}
return `${newCat}/${newName}`;
}
// Helper: borrar la imagen de un producto (best-effort).
async function deleteProductImage(img) {
if (!img) return;
const imgPath = path.join(IMG_DIR, img);
try {
await fs.unlink(imgPath);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
}
// ════════════════════════════════
// MIDDLEWARES DE AUTENTICACIÓN
// ════════════════════════════════
async function requireAuth(req, res, next) {
const session = await getSessionUser(req);
if (!session) return res.status(401).json({ error: 'No autorizado' });
req.user = session;
next();
}
async function requireAdmin(req, res, next) {
const session = await getSessionUser(req);
if (!session) return res.status(401).json({ error: 'No autorizado' });
const user = await getUserData(session.usuario);
if (!user) return res.status(401).json({ error: 'No autorizado' });
if (user.rol !== 'admin') {
return res.status(403).json({ error: 'Se requiere rol de administrador' });
}
req.user = user;
next();
}
// Permite acceder si es admin O tiene el permiso 'usuarios'
async function requireUserManagement(req, res, next) {
const session = await getSessionUser(req);
if (!session) return res.status(401).json({ error: 'No autorizado' });
const user = await getUserData(session.usuario);
if (!user) return res.status(401).json({ error: 'No autorizado' });
const esAdmin = user.rol === 'admin';
const tienePermiso = (user.permisos || []).includes('usuarios');
if (!esAdmin && !tienePermiso) {
return res.status(403).json({ error: 'No tienes permiso para gestionar usuarios' });
}
req.user = user;
next();
}
// Permite acceder si es admin O tiene el permiso 'categorias'
async function requireCategoriasManagement(req, res, next) {
const session = await getSessionUser(req);
if (!session) return res.status(401).json({ error: 'No autorizado' });
const user = await getUserData(session.usuario);
if (!user) return res.status(401).json({ error: 'No autorizado' });
const esAdmin = user.rol === 'admin';
const tienePermiso = (user.permisos || []).includes('categorias');
if (!esAdmin && !tienePermiso) {
return res.status(403).json({ error: 'No tienes permiso para gestionar categorías' });
}
req.user = user;
next();
}
// Permite acceder si es admin O tiene el permiso 'productos'
async function requireProductosManagement(req, res, next) {
const session = await getSessionUser(req);
if (!session) return res.status(401).json({ error: 'No autorizado' });
const user = await getUserData(session.usuario);
if (!user) return res.status(401).json({ error: 'No autorizado' });
const esAdmin = user.rol === 'admin';
const tienePermiso = (user.permisos || []).includes('productos');
if (!esAdmin && !tienePermiso) {
return res.status(403).json({ error: 'No tienes permiso para gestionar productos' });
}
req.user = user;
next();
}
// ════════════════════════════════
// RUTAS DE AUTENTICACIÓN
// ════════════════════════════════
app.post('/api/auth/login', async (req, res) => {
const { usuario, contraseña } = req.body || {};
if (!usuario || !contraseña) {
return res.status(400).json({ error: 'Usuario y contraseña requeridos' });
}
try {
const data = await readJson('contraseñas.json');
const usuarios = data.usuarios || [];
const match = usuarios.find(u => u.usuario === usuario && u.contraseña === contraseña);
if (!match) return res.status(401).json({ error: 'Credenciales inválidas' });
const token = await createSession(match);
res.cookie('session', token, {
signed: true,
httpOnly: true,
sameSite: 'lax',
secure: false, // ⚠️ cambiar a true cuando se use HTTPS
maxAge: 24 * 60 * 60 * 1000
});
res.json({ ok: true, usuario: match.usuario, rol: match.rol });
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Error en el servidor' });
}
});
app.post('/api/auth/logout', async (req, res) => {
const token = req.signedCookies.session;
if (token) await destroySession(token);
res.clearCookie('session');
res.json({ ok: true });
});
app.get('/api/auth/me', async (req, res) => {
const session = await getSessionUser(req);
if (!session) return res.status(401).json({ error: 'No autenticado' });
const user = await getUserData(session.usuario);
if (!user) return res.status(401).json({ error: 'Usuario no encontrado' });
res.json({
usuario: user.usuario,
rol: user.rol,
permisos: user.permisos || []
});
});
app.put('/api/auth/password', requireAdmin, async (req, res) => {
const { actual, nueva } = req.body || {};
if (!actual || !nueva) {
return res.status(400).json({ error: 'Contraseña actual y nueva requeridas' });
}
if (nueva.length < 6) {
return res.status(400).json({ error: 'La nueva contraseña debe tener al menos 6 caracteres' });
}
try {
const data = await readJson('contraseñas.json');
const usuarios = data.usuarios || [];
const idx = usuarios.findIndex(u => u.usuario === req.user.usuario);
if (idx === -1) return res.status(404).json({ error: 'Usuario no encontrado' });
if (usuarios[idx].contraseña !== actual) {
return res.status(401).json({ error: 'La contraseña actual es incorrecta' });
}
usuarios[idx].contraseña = nueva;
await writeJson('contraseñas.json', { usuarios });
res.json({ ok: true });
} catch (err) {
console.error('Change password error:', err);
res.status(500).json({ error: 'Error al cambiar contraseña' });
}
});
// ════════════════════════════════
// RUTAS PÚBLICAS (catálogo)
// ════════════════════════════════
app.get('/api/categorias', async (req, res) => {
try {
await ensureSinCategoria();
const categorias = await getCategorias();
res.json(categorias);
} catch {
res.json([]);
}
});
app.get('/api/productos', async (req, res) => {
try {
const data = await readJson('productos.json');
res.json(data);
} catch {
res.json([]);
}
});
// ════════════════════════════════
// GESTIÓN DE CATEGORÍAS (admin o permiso 'categorias')
// ════════════════════════════════
app.post('/api/categorias', requireCategoriasManagement, async (req, res) => {
const { nombre, icono } = req.body || {};
if (!nombre || !nombre.trim()) {
return res.status(400).json({ error: 'El nombre es obligatorio' });
}
const id = slugify(nombre);
if (!id) {
return res.status(400).json({ error: 'El nombre genera un ID inválido' });
}
try {
const categorias = await getCategorias();
const nombreNorm = nombre.trim().toLowerCase();
// No permitir duplicados por id NI por nombre
if (categorias.some(c => c.id === id)) {
return res.status(409).json({ error: 'Ya existe una categoría con ese nombre' });
}
if (categorias.some(c => c.nombre.toLowerCase() === nombreNorm)) {
return res.status(409).json({ error: 'Ya existe una categoría con ese nombre' });
}
// Crear carpeta física
const dir = path.join(IMG_DIR, id);
try {
await fs.access(dir);
// Si ya existe la carpeta, no es error — solo se agrega al JSON
} catch {
await fs.mkdir(dir, { recursive: true });
}
const nuevaCat = { id, nombre: nombre.trim(), icono: icono || '📦' };
categorias.push(nuevaCat);
await saveCategorias(categorias);
res.json({ ok: true, categoria: nuevaCat });
} catch (err) {
console.error('Create category error:', err);
res.status(500).json({ error: 'Error al crear la categoría' });
}
});
app.put('/api/categorias/:id', requireCategoriasManagement, async (req, res) => {
const targetId = req.params.id;
const { nombre, icono } = req.body || {};
if (!nombre || !nombre.trim()) {
return res.status(400).json({ error: 'El nombre es obligatorio' });
}
try {
const categorias = await getCategorias();
const idx = categorias.findIndex(c => c.id === targetId);
if (idx === -1) return res.status(404).json({ error: 'Categoría no encontrada' });
if (targetId === SIN_CATEGORIA_ID) {
return res.status(400).json({ error: 'No se puede editar la categoría del sistema' });
}
const nombreNorm = nombre.trim().toLowerCase();
// No permitir que el nuevo nombre coincida con OTRA categoría
if (categorias.some((c, i) => i !== idx && c.nombre.toLowerCase() === nombreNorm)) {
return res.status(409).json({ error: 'Ya existe otra categoría con ese nombre' });
}
// El id NO se modifica (es inmutable). Solo nombre e icono.
categorias[idx].nombre = nombre.trim();
if (icono) categorias[idx].icono = icono;
await saveCategorias(categorias);
res.json({ ok: true, categoria: categorias[idx] });
} catch (err) {
console.error('Update category error:', err);
res.status(500).json({ error: 'Error al actualizar la categoría' });
}
});
app.delete('/api/categorias/:id', requireCategoriasManagement, async (req, res) => {
const targetId = req.params.id;
if (targetId === SIN_CATEGORIA_ID) {
return res.status(400).json({ error: 'No se puede eliminar la categoría del sistema' });
}
try {
const categorias = await getCategorias();
const idx = categorias.findIndex(c => c.id === targetId);
if (idx === -1) return res.status(404).json({ error: 'Categoría no encontrada' });
// Asegurar "sin-categoria" existe
await ensureSinCategoria();
// Contar productos en esta categoría
const productos = await getProductos();
const itemsAfectados = productos.filter(p => p.cat === targetId);
if (itemsAfectados.length > 0) {
// Mover imágenes de Imagenes/<targetId>/ → Imagenes/sin-categoria/
const srcDir = path.join(IMG_DIR, targetId);
const dstDir = path.join(IMG_DIR, SIN_CATEGORIA_ID);
try {
const files = await fs.readdir(srcDir);
for (const file of files) {
const srcFile = path.join(srcDir, file);
const dstFile = path.join(dstDir, file);
// Mover (rename). Si existe destino, sobreescribir (cada producto tiene img único).
try {
await fs.rename(srcFile, dstFile);
} catch (err) {
// Si falla el rename (p.ej. cross-device), copiar y borrar
await fs.copyFile(srcFile, dstFile);
await fs.unlink(srcFile);
}
}
// Borrar carpeta origen (vacía)
try {
await fs.rmdir(srcDir);
} catch {
// Si quedan subcarpetas u otros archivos, no fallar
}
} catch (err) {
// Si la carpeta no existe, no es error
if (err.code !== 'ENOENT') throw err;
}
// Actualizar productos: cat → sin-categoria, img → sin-categoria/...
for (const p of productos) {
if (p.cat === targetId) {
p.cat = SIN_CATEGORIA_ID;
if (p.img && p.img.startsWith(targetId + '/')) {
p.img = SIN_CATEGORIA_ID + '/' + p.img.slice(targetId.length + 1);
}
}
}
await saveProductos(productos);
} else {
// Sin items: solo borrar carpeta y entrada del JSON
const srcDir = path.join(IMG_DIR, targetId);
try {
const files = await fs.readdir(srcDir);
for (const file of files) {
await fs.unlink(path.join(srcDir, file));
}
await fs.rmdir(srcDir);
} catch (err) {
if (err.code !== 'ENOENT') console.error('Error borrando carpeta:', err);
}
}
// Quitar del JSON
categorias.splice(idx, 1);
await saveCategorias(categorias);
res.json({ ok: true, itemsMovidos: itemsAfectados.length });
} catch (err) {
console.error('Delete category error:', err);
res.status(500).json({ error: 'Error al eliminar la categoría' });
}
});
// ════════════════════════════════
// SUBCATEGORÍAS (anidadas, máx 3 niveles)
// CRUD separado del de categorías para validaciones más finas.
// No afecta productos todavía (esa integración viene en el siguiente paso).
// ════════════════════════════════
// POST /api/categorias/:id/subcategorias → crear subcat dentro de :id
// Body: { nombre, parentSubId? }
// - Sin parentSubId: crea subcat nivel 1 (directo dentro de la categoría)
// - Con parentSubId: crea sub-subcat (nivel 2) dentro de la subcat indicada
app.post('/api/categorias/:id/subcategorias', requireCategoriasManagement, async (req, res) => {
const parentId = req.params.id;
const { nombre, parentSubId } = req.body || {};
if (!nombre || !String(nombre).trim() || String(nombre).length > 40) {
return res.status(400).json({ error: 'Nombre inválido (1-40 caracteres)' });
}
if (parentId === SIN_CATEGORIA_ID) {
return res.status(400).json({ error: 'No se pueden agregar subcategorías a "Sin categoría"' });
}
try {
const categorias = await getCategorias();
const parent = categorias.find(c => c.id === parentId);
if (!parent) return res.status(404).json({ error: 'Categoría padre no encontrada' });
// Determinar el array destino: subcategorías de la categoría, o de la sub-sub
let targetArray;
if (parentSubId) {
// Creando sub-sub: el "padre" es una subcat existente
const subParent = Array.isArray(parent.subcategorias)
? parent.subcategorias.find(s => s.id === parentSubId)
: null;
if (!subParent) return res.status(404).json({ error: 'Subcategoría padre no encontrada' });
if (!Array.isArray(subParent.subcategorias)) subParent.subcategorias = [];
targetArray = subParent.subcategorias;
} else {
// Creando subcat nivel 1
if (!Array.isArray(parent.subcategorias)) parent.subcategorias = [];
targetArray = parent.subcategorias;
}
// Límite de profundidad: máximo 3 niveles (cat > sub > sub-sub).
// Si estamos creando nivel 1 y la categoría ya tiene sub-subcats (de otras ramas),
// todavía está OK (cada rama puede tener sub-sub independientes).
// Lo que NO permitimos es crear un nivel 3 (porque la URL no lo soporta y la UI no lo ofrece).
// Generar id (slug) y validar duplicado dentro del padre directo
const slug = slugify(nombre);
if (!slug) return res.status(400).json({ error: 'Nombre inválido' });
const idsHermanos = new Set(targetArray.map(s => s.id));
if (idsHermanos.has(slug)) {
return res.status(409).json({ error: `Ya existe una subcategoría "${slug}" aquí` });
}
targetArray.push({ id: slug, nombre: String(nombre).trim() });
await saveCategorias(categorias);
res.json({ ok: true, subcategoria: { id: slug, nombre: String(nombre).trim() } });
} catch (err) {
console.error('Create subcategory error:', err);
res.status(500).json({ error: 'Error al crear subcategoría' });
}
});
// PUT /api/categorias/:id/subcategorias/:subId → editar nombre (id inmutable)
// Busca recursivamente en el árbol: :id es la categoría raíz, :subId puede estar en cualquier nivel.
app.put('/api/categorias/:id/subcategorias/:subId', requireCategoriasManagement, async (req, res) => {
const { id: parentId, subId } = req.params;
const { nombre } = req.body || {};
if (!nombre || !String(nombre).trim() || String(nombre).length > 40) {
return res.status(400).json({ error: 'Nombre inválido (1-40 caracteres)' });
}
try {
const categorias = await getCategorias();
const parent = categorias.find(c => c.id === parentId);
if (!parent) return res.status(404).json({ error: 'Categoría padre no encontrada' });
const sub = findSubcatRecursive(parent.subcategorias, subId);
if (!sub) return res.status(404).json({ error: 'Subcategoría no encontrada' });
sub.nombre = String(nombre).trim();
await saveCategorias(categorias);
res.json({ ok: true, subcategoria: { id: sub.id, nombre: sub.nombre } });
} catch (err) {
console.error('Edit subcategory error:', err);
res.status(500).json({ error: 'Error al editar subcategoría' });
}
});
// DELETE /api/categorias/:id/subcategorias/:subId → eliminar (mover productos al padre)
// Solo se permite borrar hojas (subcats sin sub-subcats). Si tiene sub-subcats, debe borrarlas primero.
app.delete('/api/categorias/:id/subcategorias/:subId', requireCategoriasManagement, async (req, res) => {
const { id: parentId, subId } = req.params;
try {
const categorias = await getCategorias();
const parent = categorias.find(c => c.id === parentId);
if (!parent) return res.status(404).json({ error: 'Categoría padre no encontrada' });
const sub = findSubcatRecursive(parent.subcategorias, subId);
if (!sub) return res.status(404).json({ error: 'Subcategoría no encontrada' });
if (Array.isArray(sub.subcategorias) && sub.subcategorias.length) {
return res.status(400).json({ error: 'Primero elimina las sub-subcategorías' });
}
// Quitar del árbol (recursivo, encuentra el padre correcto y splice)
removeSubcatRecursive(parent.subcategorias, subId);
// Mantener array limpio: si la raíz quedó sin subcats, eliminamos la propiedad
if (Array.isArray(parent.subcategorias) && parent.subcategorias.length === 0) {
delete parent.subcategorias;
}
await saveCategorias(categorias);
res.json({ ok: true });
} catch (err) {
console.error('Delete subcategory error:', err);
res.status(500).json({ error: 'Error al eliminar subcategoría' });
}
});
// ════════════════════════════════
// GESTIÓN DE USUARIOS (admin o permiso 'usuarios')
// ════════════════════════════════
app.get('/api/usuarios', requireUserManagement, async (req, res) => {
try {
const data = await readJson('contraseñas.json');
const usuarios = (data.usuarios || []).map(u => ({
usuario: u.usuario,
rol: u.rol,
permisos: u.permisos || []
}));
res.json(usuarios);
} catch (err) {
console.error('List users error:', err);
res.status(500).json({ error: 'Error al cargar usuarios' });
}
});
app.post('/api/usuarios', requireUserManagement, async (req, res) => {
const { usuario, contraseña, rol, permisos } = req.body || {};
if (!usuario || !contraseña || !rol) {
return res.status(400).json({ error: 'Todos los campos son obligatorios' });
}
if (contraseña.length < 6) {
return res.status(400).json({ error: 'La contraseña debe tener al menos 6 caracteres' });
}
if (!['admin', 'editor', 'vendedor'].includes(rol)) {
return res.status(400).json({ error: 'Rol inválido' });
}
try {
const data = await readJson('contraseñas.json');
const usuarios = data.usuarios || [];
if (usuarios.some(u => u.usuario === usuario)) {
return res.status(409).json({ error: 'Ya existe un usuario con ese nombre' });
}
// Admin siempre tiene todos los permisos; editor/vendedor reciben la lista enviada
const permisosFinal = rol === 'admin'
? []
: (Array.isArray(permisos) ? permisos : []);
usuarios.push({ usuario, contraseña, rol, permisos: permisosFinal });
await writeJson('contraseñas.json', { usuarios });
res.json({ ok: true });
} catch (err) {
console.error('Create user error:', err);
res.status(500).json({ error: 'Error al crear usuario' });
}
});
app.put('/api/usuarios/:usuario', requireUserManagement, async (req, res) => {
const targetUsuario = req.params.usuario;
const { contraseña, rol, permisos } = req.body || {};
if (rol && !['admin', 'editor', 'vendedor'].includes(rol)) {
return res.status(400).json({ error: 'Rol inválido' });
}
if (contraseña && contraseña.length < 6) {
return res.status(400).json({ error: 'La contraseña debe tener al menos 6 caracteres' });
}
try {
const data = await readJson('contraseñas.json');
const usuarios = data.usuarios || [];
const idx = usuarios.findIndex(u => u.usuario === targetUsuario);
if (idx === -1) return res.status(404).json({ error: 'Usuario no encontrado' });
if (rol && rol !== 'admin' && usuarios[idx].rol === 'admin') {
const admins = usuarios.filter(u => u.rol === 'admin');
if (admins.length === 1) {
return res.status(400).json({ error: 'No puedes degradar al único administrador' });
}
}
if (contraseña) usuarios[idx].contraseña = contraseña;
if (rol) usuarios[idx].rol = rol;
// Si cambia a admin, limpiamos permisos (admin siempre tiene todos)
if (rol === 'admin') {
usuarios[idx].permisos = [];
} else if (Array.isArray(permisos)) {
usuarios[idx].permisos = permisos;
}
await writeJson('contraseñas.json', { usuarios });
res.json({ ok: true });
} catch (err) {
console.error('Update user error:', err);
res.status(500).json({ error: 'Error al actualizar usuario' });
}
});
app.delete('/api/usuarios/:usuario', requireUserManagement, async (req, res) => {
const targetUsuario = req.params.usuario;
if (targetUsuario === req.user.usuario) {
return res.status(400).json({ error: 'No puedes eliminarte a ti mismo' });
}
try {
const data = await readJson('contraseñas.json');
const usuarios = data.usuarios || [];
const target = usuarios.find(u => u.usuario === targetUsuario);
if (!target) return res.status(404).json({ error: 'Usuario no encontrado' });
if (target.rol === 'admin') {
const admins = usuarios.filter(u => u.rol === 'admin');
if (admins.length === 1) {
return res.status(400).json({ error: 'No puedes eliminar al único administrador' });
}
}
const filtrados = usuarios.filter(u => u.usuario !== targetUsuario);
await writeJson('contraseñas.json', { usuarios: filtrados });
res.json({ ok: true });
} catch (err) {
console.error('Delete user error:', err);
res.status(500).json({ error: 'Error al eliminar usuario' });
}
});
// ════════════════════════════════
// SUBIDA MASIVA DE PRODUCTOS (admin o permiso 'productos')
// ════════════════════════════════
// Multer config: usa memoria (luego sharp lo procesa y guarda en disco).
const MAX_FILES = 50;
const MAX_SIZE = 20 * 1024 * 1024; // 20MB por archivo
const MAX_WIDTH = 1200; // ancho (o alto) máx del webp resultante
const WEBP_QUALITY = 80;
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_SIZE, files: MAX_FILES }
});
// Convención de nombres de archivo:
// La REFERENCIA es TODO el nombre antes de la extensión, sin importar
// qué tan complejo sea (puede tener guiones, guiones bajos, números, etc.).
// Cada archivo = 1 producto con 1 foto.
//
// "bill-001-1.jpg" → ref=bill-001-1
// "bill-001-2.jpg" → ref=bill-001-2 (producto DISTINTO al anterior)
// "GG-500505.jpg" → ref=GG-500505
// "mi-prod_loco.jpg" → ref=mi-prod_loco
//
// La referencia es el IDENTIFICATIVO del producto (como un SKU).
// Más adelante, cuando se suba el Excel/CSV, se matcheará por referencia
// para asignar la descripción (nombre visible) y el precio.
const REF_VALID_RE = /^[a-zA-Z0-9]+(?:[-_a-zA-Z0-9]*[a-zA-Z0-9])?$/;
function parseFilename(name) {
// Quitar extensión (todo lo que está después del último punto)
const base = name.replace(/\.[^.]+$/, '').trim();
if (!base) return null;
// Validar que la referencia tenga un formato aceptable
if (!REF_VALID_RE.test(base)) return null;
return { ref: base, num: 1 };
}
app.post('/api/productos/upload', requireProductosManagement, upload.array('fotos', MAX_FILES), async (req, res) => {
const categoria = (req.body.categoria || '').trim();
const subRaw = req.body.sub || '[]';
const files = req.files || [];
if (!categoria) {
return res.status(400).json({ error: 'Selecciona una categoría' });
}
if (files.length === 0) {
return res.status(400).json({ error: 'No se enviaron fotos' });
}
// Parsear la cadena de subcategorías (viene como JSON string desde el form data)
let subPath;
try {
subPath = JSON.parse(subRaw);
if (!Array.isArray(subPath)) throw new Error();
} catch {
return res.status(400).json({ error: 'Subcategoría inválida' });
}
try {
// Verificar que la categoría existe y NO es sin-categoria
const categorias = await getCategorias();
const cat = categorias.find(c => c.id === categoria);
if (!cat) {
return res.status(400).json({ error: 'La categoría no existe' });
}
if (categoria === SIN_CATEGORIA_ID) {
return res.status(400).json({ error: 'No se puede subir productos a la categoría del sistema' });
}
// Validar la cadena de subcats contra el árbol:
// - La categoría tiene subcats → subPath DEBE tener al menos 1 elemento (la hoja final)
// - La categoría NO tiene subcats → subPath debe estar vacío
// - Cada sub debe existir en su nivel y el último debe ser HOJA (sin sub-subs)
const catTieneSubcats = Array.isArray(cat.subcategorias) && cat.subcategorias.length > 0;
if (catTieneSubcats && subPath.length === 0) {
return res.status(400).json({ error: `"${cat.nombre}" tiene subcategorías, debes elegir una hoja` });
}
if (!catTieneSubcats && subPath.length > 0) {
return res.status(400).json({ error: `"${cat.nombre}" no tiene subcategorías` });
}
// Caminar el árbol validando cada nivel
let nivelActual = cat.subcategorias || [];
for (let i = 0; i < subPath.length; i++) {
const id = subPath[i];
const nodo = nivelActual.find(s => s.id === id);
if (!nodo) {
return res.status(400).json({ error: `Subcategoría "${id}" no encontrada en nivel ${i + 1}` });
}
const esUltimo = i === subPath.length - 1;
if (esUltimo) {
// La hoja final NO debe tener sub-subcategorías (solo las hojas reciben productos)
if (Array.isArray(nodo.subcategorias) && nodo.subcategorias.length > 0) {
return res.status(400).json({ error: `"${nodo.nombre}" tiene sub-subcategorías, debes elegir una de ellas` });
}
} else {
// No es el último → debe tener subcategorías para seguir bajando
if (!Array.isArray(nodo.subcategorias) || nodo.subcategorias.length === 0) {
return res.status(400).json({ error: `"${nodo.nombre}" no tiene sub-subcategorías` });
}
nivelActual = nodo.subcategorias;
}
}
// Construir el path destino: <cat> o <cat>/<sub1> o <cat>/<sub1>/<sub2>
const pathParts = [categoria, ...subPath];
const relDir = pathParts.join('/');
const dstDir = path.join(IMG_DIR, relDir);
await fs.mkdir(dstDir, { recursive: true });
// Cargar productos existentes
const productos = await getProductos();
const existentes = new Set(productos.map(p => p.ref || p.id));
const creados = [];
const saltados = [];
const ignorados = [];
const errores = [];
// Cada archivo = 1 producto con 1 foto. Procesamos secuencialmente.
for (const f of files) {
const parsed = parseFilename(f.originalname);
if (!parsed) {
ignorados.push({ archivo: f.originalname, motivo: 'Nombre de archivo inválido' });
continue;
}
const { ref } = parsed;
if (existentes.has(ref)) {
saltados.push({ ref, motivo: 'Ya existe un producto con esa referencia' });
continue;
}
try {
const outName = `${ref}.webp`;
const outPath = path.join(dstDir, outName);
await sharp(f.buffer)
.rotate() // respeta EXIF orientation
.resize({
width: MAX_WIDTH,
height: MAX_WIDTH,
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: WEBP_QUALITY })
.toFile(outPath);
productos.push({
id: ref,
ref: ref,
cat: categoria,
sub: subPath,
img: `${relDir}/${outName}`
});
creados.push({ ref });
} catch (err) {
console.error(`Error procesando ${ref}:`, err.message);
errores.push({ ref, motivo: 'Error al procesar la imagen' });
}
}
await saveProductos(productos);
res.json({
ok: true,
resumen: {
creados: creados.length,
creadosList: creados,
saltados: saltados.length,
saltadosList: saltados,
ignorados: ignorados.length,
ignoradosList: ignorados,
errores: errores.length,
erroresList: errores
}
});
} catch (err) {
console.error('Upload productos error:', err);
res.status(500).json({ error: 'Error al procesar la subida' });
}
});
// Marcar como AGOTADO (admin o permiso 'productos')
// Mueve la imagen a Imagenes/agotados/<ref>.webp y actualiza el JSON.
// El producto sigue en el JSON (con su categoría original), pero su `img`
// pasa a apuntar a "agotados/<ref>.webp", que es la señal de "agotado".
app.post('/api/productos/:ref/agotar', requireProductosManagement, async (req, res) => {
const ref = req.params.ref;
try {
const productos = await getProductos();
const idx = productos.findIndex(p => p.ref === ref);
if (idx === -1) return res.status(404).json({ error: 'Producto no encontrado' });
if (productos[idx].img && productos[idx].img.startsWith('agotados/')) {
return res.status(400).json({ error: 'El producto ya está agotado' });
}
const oldImg = productos[idx].img;
const newImg = await moveProductImage(oldImg, 'agotados', ref);
productos[idx].img = newImg;
await saveProductos(productos);
res.json({ ok: true, ref, img: newImg });
} catch (err) {
console.error('Agotar error:', err);
res.status(500).json({ error: 'Error al marcar como agotado' });
}
});
// REACTIVAR un producto agotado (admin o permiso 'productos')
// Mueve la imagen de vuelta a su carpeta de categoría original
// (o a "sin-categoria" si la categoría ya no existe) y actualiza el JSON.
app.post('/api/productos/:ref/reactivar', requireProductosManagement, async (req, res) => {
const ref = req.params.ref;
try {
const productos = await getProductos();
const idx = productos.findIndex(p => p.ref === ref);
if (idx === -1) return res.status(404).json({ error: 'Producto no encontrado' });
if (!productos[idx].img || !productos[idx].img.startsWith('agotados/')) {
return res.status(400).json({ error: 'El producto no está agotado' });
}
// Categoría original (la guardamos en el JSON al subir)
const targetCat = productos[idx].cat;
if (!targetCat) {
return res.status(400).json({ error: 'El producto no tiene categoría original' });
}
// Si la categoría original ya no existe, fallback a "sin-categoria"
const categorias = await getCategorias();
let destCat = targetCat;
if (!categorias.some(c => c.id === targetCat)) {
await ensureSinCategoria();
destCat = SIN_CATEGORIA_ID;
productos[idx].cat = SIN_CATEGORIA_ID;
}
const newImg = await moveProductImage(productos[idx].img, destCat, ref);
productos[idx].img = newImg;
await saveProductos(productos);
res.json({ ok: true, ref, img: newImg, cat: destCat });
} catch (err) {
console.error('Reactivar error:', err);
res.status(500).json({ error: 'Error al reactivar el producto' });
}
});
// ELIMINAR DEFINITIVAMENTE un producto (admin o permiso 'productos')
// Borra la imagen del disco y la entrada del JSON.
// Funciona tanto para productos disponibles como para agotados.
app.delete('/api/productos/:ref', requireProductosManagement, async (req, res) => {
const ref = req.params.ref;
try {
const productos = await getProductos();
const idx = productos.findIndex(p => p.ref === ref);
if (idx === -1) return res.status(404).json({ error: 'Producto no encontrado' });
const img = productos[idx].img;
await deleteProductImage(img);
productos.splice(idx, 1);
await saveProductos(productos);
res.json({ ok: true, ref });
} catch (err) {
console.error('Delete producto error:', err);
res.status(500).json({ error: 'Error al eliminar el producto' });
}
});
// ════════════════════════════════
// ASIGNAR NOMBRE + PRECIO DESDE EXCEL/CSV (admin o permiso 'productos')
//
// El archivo debe tener encabezados en la primera fila:
// A: REFERENCIA | B: DESCRIPCION | C: PRECIO
//
// Reglas:
// - El Excel/CSV NO crea productos nuevos. Solo actualiza los que ya
// existen en productos.json (match por `ref` exacto).
// - Si una celda está vacía, NO sobrescribe el valor actual.
// - Precio acepta: "50000", "$ 50.000", "50,000.00", "50.000,50", etc.
// - Si un `ref` del archivo no existe, se reporta en "noEncontrados".
// ════════════════════════════════
// Helper: normalizar texto para comparar headers (trim, lowercase, sin acentos)
function normHeader(s) {
return String(s || '')
.trim()
.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
// Helper: parsear precio en múltiples formatos (CO/US/EU)
// "50000" → 50000
// "$ 50.000" → 50000 (formato CO: punto = miles)
// "50,000.00" → 50000 (formato US: coma = miles, punto = decimal)
// "50.000,50" → 50000.5 (formato CO/EU: punto = miles, coma = decimal)
// " $1.234,56" → 1234.56
// "75.500" → 75500 (heurística: 3 dígitos tras último punto = miles)
// "75.5" → 75.5 (1-2 dígitos tras último punto = decimal)
function parsePrice(value) {
if (value === null || value === undefined || value === '') return null;
let s = String(value).trim();
if (!s) return null;
// Quitar símbolos de moneda y espacios
s = s.replace(/[$€£¥₹\s]/g, '');
if (!s) return null;
const isNegative = (s.startsWith('-') || (s.startsWith('(') && s.endsWith(')')));
s = s.replace(/^[-()]/, '').replace(/\)$/, '');
const lastComma = s.lastIndexOf(',');
const lastDot = s.lastIndexOf('.');
let normalized;
if (lastComma !== -1 && lastDot !== -1) {
// Hay ambos separadores: el ÚLTIMO es el decimal
if (lastComma > lastDot) {
// Formato CO/EU: "1.000,50" → coma es decimal
normalized = s.replace(/\./g, '').replace(',', '.');
} else {
// Formato US: "1,000.50" → punto es decimal
normalized = s.replace(/,/g, '');
}
} else if (lastComma !== -1) {
// Solo comas. Si la última parte tiene 1-2 dígitos, decimal; si no, miles
const parts = s.split(',');
if (parts.length === 2 && parts[1].length <= 2) {
normalized = s.replace(',', '.');
} else {
normalized = s.replace(/,/g, '');
}
} else if (lastDot !== -1) {
// Solo puntos. Heurística: si la parte tras el último punto tiene 3 dígitos
// (y no hay otro punto), es separador de miles (formato CO: "75.000" = 75000).
// Si tiene 1-2 dígitos, es decimal ("75.5" = 75.5).
const afterLastDot = s.slice(lastDot + 1);
if (s.split('.').length === 2 && afterLastDot.length === 3 && /^\d+$/.test(afterLastDot)) {
// Formato CO sin comas: "75.000" = 75000
normalized = s.replace(/\./g, '');
} else {
// Decimal: "75.5", "75.50", "1.5.5" (raro pero lo dejamos pasar)
normalized = s;
}
} else {
normalized = s;
}
const num = parseFloat(normalized);
if (isNaN(num)) return null;
return isNegative ? -num : num;
}
// Multer específico para el Excel (1 solo archivo, hasta 5MB)
const excelUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024, files: 1 }
});
app.post('/api/productos/excel', requireProductosManagement, excelUpload.single('archivo'), async (req, res) => {
const file = req.file;
if (!file) {
return res.status(400).json({ error: 'No se envió ningún archivo' });
}
try {
// Parsear el archivo (.xlsx o .csv). SheetJS detecta el formato por contenido.
// codepage: 65001 fuerza UTF-8 (necesario para acentos/ñ en CSV).
const wb = XLSX.read(file.buffer, { type: 'buffer', codepage: 65001 });
const sheetName = wb.SheetNames[0];
if (!sheetName) {
return res.status(400).json({ error: 'El archivo no tiene hojas' });
}
const sheet = wb.Sheets[sheetName];
// header: 1 → array de arrays. raw: false devuelve el valor FORMATEADO de la celda
// (preserva strings tal cual aparecen, ej: "45.000,50" se queda como string).
// Esto es crítico para no interpretar mal separadores de miles/decimales.
// defval: '' para celdas vacías.
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: false });
if (rows.length === 0) {
return res.status(400).json({ error: 'El archivo está vacío' });
}
// Validar encabezados (case-insensitive, sin acentos)
const headersRaw = rows[0].map(h => normHeader(h));
const expected = ['referencia', 'descripcion', 'precio'];
if (
headersRaw.length < 3 ||
headersRaw[0] !== expected[0] ||
headersRaw[1] !== expected[1] ||
headersRaw[2] !== expected[2]
) {
return res.status(400).json({
error: `Los encabezados deben ser exactamente: ${expected.join(' | ').toUpperCase()}. Encontrado: ${rows[0].map(h => String(h).trim() || '(vacío)').join(' | ').toUpperCase()}`
});
}
// Procesar filas
const productos = await getProductos();
const productosByRef = new Map(productos.map(p => [String(p.ref), p]));
const actualizados = [];
const noEncontrados = [];
const errores = [];
const filasVacias = [];
let filasConDatos = 0;
for (let i = 1; i < rows.length; i++) {
const row = rows[i];
// Saltar filas completamente vacías
const isEmpty = row.every(c => c === '' || c === null || c === undefined);
if (isEmpty) {
filasVacias.push(i + 1); // 1-indexed para el usuario
continue;
}
const refRaw = row[0] != null ? String(row[0]).trim() : '';
const descRaw = row[1] != null ? String(row[1]).trim() : '';
const precioRaw = row[2];
if (!refRaw) {
errores.push({ fila: i + 1, motivo: 'REFERENCIA vacía' });
continue;
}
const producto = productosByRef.get(refRaw);
if (!producto) {
noEncontrados.push(refRaw);
continue;
}
// Actualizar SOLO si la celda tiene valor
let changed = false;
if (descRaw) {
producto.nombre = descRaw;
changed = true;
}
if (precioRaw !== '' && precioRaw !== null && precioRaw !== undefined) {
// XLSX puede entregar números o strings según formato de celda
let precio;
if (typeof precioRaw === 'number') {
precio = precioRaw;
} else {
precio = parsePrice(precioRaw);
}
if (precio === null || isNaN(precio)) {
errores.push({ fila: i + 1, ref: refRaw, motivo: `Precio inválido: "${precioRaw}"` });
continue;
}
producto.precio = precio;
changed = true;
}
if (changed) {
actualizados.push({ ref: refRaw, fila: i + 1 });
filasConDatos++;
}
}
await saveProductos(productos);
res.json({
ok: true,
resumen: {
actualizados: actualizados.length,
actualizadosList: actualizados,
noEncontrados: noEncontrados.length,
noEncontradosList: noEncontrados,
errores: errores.length,
erroresList: errores,
filasVacias: filasVacias.length,
filasVaciasList: filasVacias
}
});
} catch (err) {
console.error('Excel upload error:', err);
res.status(500).json({ error: 'Error al procesar el archivo: ' + err.message });
}
});
// Multer error handler (tamaño excedido, etc.)
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: `Cada archivo no puede superar ${MAX_SIZE / 1024 / 1024}MB` });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: `Máximo ${MAX_FILES} archivos por subida` });
}
if (err.code === 'LIMIT_UNEXPECTED_FILE') {
return res.status(400).json({ error: 'Campo de archivo inesperado' });
}
return res.status(400).json({ error: err.message });
}
next(err);
});
// ════════════════════════════════
// FALLBACK para SPA-like del admin
// ════════════════════════════════
app.get('/administrativo', (req, res) => {
res.sendFile(path.join(FRONTEND_DIR, 'administrativo', 'index.html'));
});
app.get('/administrativo/', (req, res) => {
res.sendFile(path.join(FRONTEND_DIR, 'administrativo', 'index.html'));
});
// ════════════════════════════════
// LIMPIEZA DE SESIONES A LAS 00:00 (America/Bogota)
// Vacía sesiones.json todos los días a medianoche hora Colombia.
// Cuando los usuarios abran la app después de las 00:00, tendrán
// que volver a loguearse (sus cookies quedaron firmadas pero el
// token ya no existe en sesiones.json → 401 → redirige a /login).
// ════════════════════════════════
function msUntilNextMidnightBogota() {
// Obtener la hora actual en America/Bogota (UTC-5, sin DST)
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Bogota',
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const parts = {};
for (const part of formatter.formatToParts(new Date())) {
if (part.type !== 'literal') parts[part.type] = part.value;
}
// En formato 24h, medianoche puede aparecer como "24" en algunos sistemas
const hour = parseInt(parts.hour === '24' ? '0' : parts.hour, 10);
const minute = parseInt(parts.minute, 10);
const second = parseInt(parts.second, 10);
const msPassedToday = (hour * 3600 + minute * 60 + second) * 1000;
return 24 * 3600 * 1000 - msPassedToday;
}
async function runSessionCleanup(logPrefix = '[cleanup]') {
try {
const sesiones = await getSesiones();
if (sesiones.length === 0) {
console.log(`${logPrefix} sesiones.json ya está vacío, nada que limpiar`);
return { removed: 0 };
}
await saveSesiones([]);
console.log(`${logPrefix}${sesiones.length} sesión(es) cerrada(s) a las 00:00 CO`);
return { removed: sesiones.length };
} catch (err) {
console.error(`${logPrefix} Error limpiando sesiones:`, err.message);
return { removed: 0, error: err.message };
}
}
function scheduleSessionCleanup() {
const ms = msUntilNextMidnightBogota();
const hours = (ms / 1000 / 60 / 60).toFixed(2);
console.log(`[cleanup] Próxima limpieza de sesiones en ${hours}h (00:00 America/Bogota)`);
setTimeout(() => {
runSessionCleanup();
// Después de la primera ejecución, repetir cada 24h
setInterval(() => runSessionCleanup(), 24 * 60 * 60 * 1000);
}, ms);
}
// ════════════════════════════════
// ENDPOINT MANUAL (admin) — útil para emergencias o testing
// Cierra TODAS las sesiones inmediatamente.
// ════════════════════════════════
app.post('/api/admin/cleanup-sessions', requireAdmin, async (req, res) => {
const result = await runSessionCleanup('[manual]');
res.json({ ok: true, ...result });
});
// ════════════════════════════════
// START
// ════════════════════════════════
app.listen(PORT, () => {
console.log(`∞ Infinity server corriendo en http://localhost:${PORT}`);
scheduleSessionCleanup();
});