/* ════════════════════════════════ 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); } 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á ".webp") // Devuelve la nueva ruta relativa ("/.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// → 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' }); } }); // ════════════════════════════════ // 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 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' }); } 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' }); } // Crear carpeta destino si no existe const dstDir = path.join(IMG_DIR, categoria); 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, img: `${categoria}/${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/.webp y actualiza el JSON. // El producto sigue en el JSON (con su categoría original), pero su `img` // pasa a apuntar a "agotados/.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(); });