/* ════════════════════════════════ 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 PDFDocument = require('pdfkit'); const archiver = require('archiver'); 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'); const PEDIDOS_DIR = path.join(__dirname, 'pedidos'); const VIDEOS_DIR = path.join(__dirname, 'videos'); // ════════════════════════════════ // MIDDLEWARES // ════════════════════════════════ app.use(express.json()); app.use(cookieParser(SECRET)); app.use(express.static(FRONTEND_DIR)); app.use('/Imagenes', express.static(IMG_DIR, { maxAge: '7d' })); app.use('/pedidos', express.static(PEDIDOS_DIR)); app.use('/videos', express.static(VIDEOS_DIR)); // 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á ".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(); } async function requirePedidosManagement(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('pedidos'); if (!esAdmin && !tienePermiso) { return res.status(403).json({ error: 'No tienes permiso para gestionar pedidos' }); } req.user = user; next(); } // Permite acceder si es admin O tiene el permiso 'descargas' async function requireDescargasAccess(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('descargas'); if (!esAdmin && !tienePermiso) { return res.status(403).json({ error: 'No tienes permiso para acceder a Descargas' }); } 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' }); } }); // ════════════════════════════════ // 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' }); } // Determinar si la subcat a eliminar es nivel 1 (hijo directo de la categoría) // o nivel 2 (sub-subcategoría, hijo de una subcat). Esto define el nuevo path // de los productos: subir un nivel. let isSubSub = false; if (Array.isArray(parent.subcategorias)) { isSubSub = !parent.subcategorias.some(s => s.id === subId); } // 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; } // Mover productos asignados a esta subcat al padre inmediato const productos = await getProductos(); let movidos = 0; for (const p of productos) { if (!Array.isArray(p.sub) || p.sub.length === 0) continue; // Si el producto está en el path de la subcat eliminada, subirlo un nivel // Ejemplo: subId es 'hombre' en [bolsos, hombre, morrales] -> queda [bolsos, hombre] // Ejemplo: subId es 'morrales' en [bolsos, hombre, morrales] -> queda [bolsos, hombre] const idx = p.sub.indexOf(subId); if (idx === -1) continue; // Calcular nuevo path y carpeta destino const oldSub = [...p.sub]; const newSub = oldSub.slice(0, idx); // elimina el subId y lo que esté debajo // Si es sub-sub (nivel 2), al subir queda en nivel 1 (dentro de la categoría). // Si es nivel 1, al subir queda vacío (producto directo de la categoría). // Actualizar JSON p.sub = newSub; // Mover imagen físicamente a la nueva carpeta if (p.img && !p.img.startsWith('agotados/')) { const oldPathParts = [p.cat, ...oldSub]; const newPathParts = [p.cat, ...newSub]; const oldDir = oldPathParts.join('/'); const newDir = newPathParts.join('/'); const oldImgPath = path.join(IMG_DIR, oldDir, `${p.ref}.webp`); const newImgDir = path.join(IMG_DIR, newDir); const newImgPath = path.join(newImgDir, `${p.ref}.webp`); try { await fs.mkdir(newImgDir, { recursive: true }); await fs.rename(oldImgPath, newImgPath); p.img = `${newDir}/${p.ref}.webp`; movidos++; } catch (err) { // Si la imagen no existe u otro error, solo actualizamos el JSON if (err.code !== 'ENOENT') { console.error(`Error moviendo imagen ${p.img}:`, err.message); } p.img = `${newDir}/${p.ref}.webp`; movidos++; } } else { movidos++; } } await saveProductos(productos); await saveCategorias(categorias); res.json({ ok: true, movidos }); } 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: o / o // 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/.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 }); }); // ════════════════════════════════ // GENERACIÓN DE PDF DEL CATÁLOGO (admin o permiso 'descargas') // // Body: { // seleccion: [{ cat: 'bolsos', sub: ['dama', 'aaa'] }, ...] // } // - cat: id de la categoría raíz // - sub: array opcional con la cadena de subcategorías (puede ser [] para "toda la categoría") // // Layout del PDF (A4 vertical): // 1. Portada con logo, título "Catálogo" y fecha // 2. Una página (o más) por cada hoja seleccionada, con: // - Subtítulo grande con la ruta completa (Cat > Sub1 > Sub2) // - Grid 2xN de productos: foto + ref + nombre + precio // 3. Footer con número de página en todas las hojas // ════════════════════════════════ app.post('/api/descargas/pdf', requireDescargasAccess, async (req, res) => { const { seleccion } = req.body || {}; if (!Array.isArray(seleccion) || seleccion.length === 0) { return res.status(400).json({ error: 'Debes seleccionar al menos una categoría o subcategoría' }); } try { const categorias = await getCategorias(); const productos = await getProductos(); // Validar que cada item de selección existe en el árbol for (const item of seleccion) { if (!item || typeof item.cat !== 'string') { return res.status(400).json({ error: 'Selección inválida' }); } const cat = categorias.find(c => c.id === item.cat); if (!cat) { return res.status(400).json({ error: `Categoría "${item.cat}" no existe` }); } if (Array.isArray(item.sub) && item.sub.length > 0) { let nodo = cat; for (let i = 0; i < item.sub.length; i++) { const subs = Array.isArray(nodo.subcategorias) ? nodo.subcategorias : []; const sig = subs.find(s => s.id === item.sub[i]); if (!sig) { return res.status(400).json({ error: `Subcategoría "${item.sub[i]}" no encontrada` }); } nodo = sig; } } } // Filtrar productos que coincidan con alguna selección. // - Excluye sin-categoria y agotados // - Excluye productos sin imagen const productosFiltrados = productos.filter(p => { if (!p.cat || p.cat === SIN_CATEGORIA_ID) return false; if (!p.img) return false; if (p.img.startsWith('agotados/')) return false; return seleccion.some(sel => { if (p.cat !== sel.cat) return false; const pSub = Array.isArray(p.sub) ? p.sub : []; const selSub = Array.isArray(sel.sub) ? sel.sub : []; // Selección "toda la categoría" (sub vacío) matchea cualquier sub del producto if (selSub.length === 0) return true; if (pSub.length < selSub.length) return false; for (let i = 0; i < selSub.length; i++) { if (pSub[i] !== selSub[i]) return false; } return true; }); }); if (productosFiltrados.length === 0) { return res.status(400).json({ error: 'No hay productos disponibles en las hojas seleccionadas' }); } // Ordenar productos por sección (cat > sub) y luego por referencia productosFiltrados.sort((a, b) => { if (a.cat !== b.cat) return a.cat.localeCompare(b.cat); const aSub = (a.sub || []).join('›'); const bSub = (b.sub || []).join('›'); if (aSub !== bSub) return aSub.localeCompare(bSub); return String(a.ref).localeCompare(String(b.ref)); }); // === Generar el PDF === const filename = `catalogo-infinity-${Date.now()}.pdf`; res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); const doc = new PDFDocument({ size: 'A4', margins: { top: 50, bottom: 50, left: 50, right: 50 }, info: { Title: 'Catálogo Infinity', Author: 'Infinity', Subject: 'Catálogo de productos' } }); doc.pipe(res); const pageW = doc.page.width; const pageH = doc.page.height; const MARGIN_X = 50; // Constantes de layout de productos const CARD_W = 240; const CARD_H = 290; const IMG_H = 180; const GUTTER = 20; const TOP_Y = 110; // bajo el subtítulo const BOTTOM_Y = pageH - 60; // deja espacio para el footer // Helper: nombre legible de la sección (cat > sub1 > sub2) function getSeccionNombre(catObj, subArr) { const parts = [catObj.nombre]; let nodo = catObj; for (const sid of subArr) { const subs = Array.isArray(nodo.subcategorias) ? nodo.subcategorias : []; const s = subs.find(x => x.id === sid); if (!s) break; parts.push(s.nombre); nodo = s; } return parts.join(' › '); } // Helper: dibuja el header de una sección (subtítulo + línea) function drawSeccionHeader(titulo) { doc.fontSize(22).fillColor('#8b5cf6').font('Helvetica-Bold') .text(titulo, MARGIN_X, 50, { width: pageW - 2 * MARGIN_X }); doc.moveTo(MARGIN_X, 90).lineTo(pageW - MARGIN_X, 90) .strokeColor('rgba(139, 92, 246, 0.5)').lineWidth(1.5).stroke(); } // Helper: dibuja el placeholder si una imagen no se pudo cargar function drawImagePlaceholder(x, y) { doc.rect(x, y, CARD_W, IMG_H).fillColor('#f5f3ff').fill(); doc.fontSize(11).fillColor('#8b8ba7').font('Helvetica') .text('Sin imagen', x, y + IMG_H / 2 - 6, { width: CARD_W, align: 'center' }); } // Helper: convierte una imagen del catálogo (webp) a un buffer JPG async function loadImageBuffer(imgRelPath) { const imgPath = path.join(IMG_DIR, imgRelPath); const buffer = await fs.readFile(imgPath); return await sharp(buffer).jpeg({ quality: 85 }).toBuffer(); } // Helper: dibuja el footer (número de página) function drawPageFooter() { const range = doc.bufferedPageRange(); for (let i = range.start; i < range.start + range.count; i++) { doc.switchToPage(i); doc.fontSize(8).fillColor('#8b8ba7').font('Helvetica') .text(`${i + 1} / ${range.count}`, MARGIN_X, pageH - 40, { width: pageW - 2 * MARGIN_X, align: 'center' }); } } // === PORTADA === doc.fontSize(80).fillColor('#8b5cf6').font('Helvetica-Bold') .text('∞', 0, 180, { align: 'center', width: pageW }); doc.fontSize(28).fillColor('#1a1a2e').font('Helvetica-Bold') .text('Infinity', 0, 290, { align: 'center', width: pageW }); doc.moveTo(200, 340).lineTo(pageW - 200, 340) .strokeColor('#8b5cf6').lineWidth(2).stroke(); doc.fontSize(40).fillColor('#1a1a2e').font('Helvetica-Bold') .text('Catálogo', 0, 380, { align: 'center', width: pageW }); const fechaStr = new Date().toLocaleDateString('es-CO', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); doc.fontSize(13).fillColor('#4a4a68').font('Helvetica') .text(fechaStr, 0, 450, { align: 'center', width: pageW }); doc.fontSize(12).fillColor('#8b8ba7').font('Helvetica') .text( `${productosFiltrados.length} producto${productosFiltrados.length === 1 ? '' : 's'}`, 0, 510, { align: 'center', width: pageW } ); doc.fontSize(10).fillColor('#8b8ba7').font('Helvetica') .text('Generado el ' + new Date().toLocaleString('es-CO'), 0, pageH - 80, { align: 'center', width: pageW }); // === SECCIONES DE PRODUCTOS === // Agrupar por sección const seccionesMap = new Map(); for (const p of productosFiltrados) { const key = `${p.cat}|${(p.sub || []).join('|')}`; if (!seccionesMap.has(key)) { seccionesMap.set(key, { cat: p.cat, sub: p.sub || [], productos: [] }); } seccionesMap.get(key).productos.push(p); } const secciones = Array.from(seccionesMap.values()).sort((a, b) => { if (a.cat !== b.cat) return a.cat.localeCompare(b.cat); return a.sub.join('|').localeCompare(b.sub.join('|')); }); for (const sec of secciones) { const catObj = categorias.find(c => c.id === sec.cat); if (!catObj) continue; const titulo = getSeccionNombre(catObj, sec.sub); // Nueva página para la sección doc.addPage(); drawSeccionHeader(titulo); // Pre-cargar imágenes en paralelo const productosConBuffer = await Promise.all( sec.productos.map(async p => { try { return { prod: p, imgBuffer: await loadImageBuffer(p.img) }; } catch (err) { console.error('Error cargando imagen para PDF:', p.img, err.message); return { prod: p, imgBuffer: null }; } }) ); let col = 0; let y = TOP_Y; for (const { prod, imgBuffer } of productosConBuffer) { const x = MARGIN_X + col * (CARD_W + GUTTER); // Salto de página si no cabe (y repetimos subtítulo) if (y + CARD_H > BOTTOM_Y) { doc.addPage(); drawSeccionHeader(titulo); y = TOP_Y; col = 0; } // Foto if (imgBuffer) { try { doc.image(imgBuffer, x, y, { fit: [CARD_W, IMG_H], align: 'center', valign: 'center' }); } catch { drawImagePlaceholder(x, y); } } else { drawImagePlaceholder(x, y); } // Referencia let textY = y + IMG_H + 8; doc.fontSize(8).fillColor('#8b8ba7').font('Helvetica') .text(`REF: ${prod.ref || '—'}`, x, textY, { width: CARD_W }); // Nombre textY += 12; doc.fontSize(11).fillColor('#1a1a2e').font('Helvetica-Bold') .text(prod.nombre || prod.ref || 'Sin nombre', x, textY, { width: CARD_W, height: 50 }); // Precio textY += 52; const precio = (prod.precio !== undefined && prod.precio !== null && prod.precio !== '') ? `$${Number(prod.precio).toLocaleString('es-CO')}` : '—'; doc.fontSize(15).fillColor('#8b5cf6').font('Helvetica-Bold') .text(precio, x, textY, { width: CARD_W }); col++; if (col >= 2) { col = 0; y += CARD_H + 20; } } } // === FOOTER con número de página === drawPageFooter(); doc.end(); } catch (err) { console.error('PDF generation error:', err); if (!res.headersSent) { res.status(500).json({ error: 'Error al generar el PDF: ' + err.message }); } } }); // ════════════════════════════════ // DESCARGAS — FOTOS (ZIP) // ════════════════════════════════ app.post('/api/descargas/fotos', requireDescargasAccess, async (req, res) => { const { seleccion } = req.body || {}; if (!Array.isArray(seleccion) || seleccion.length === 0) { return res.status(400).json({ error: 'Debes seleccionar al menos una categoría o subcategoría' }); } try { const categorias = await getCategorias(); const productos = await getProductos(); // Validar selección (misma lógica que PDF) for (const item of seleccion) { if (!item || typeof item.cat !== 'string') { return res.status(400).json({ error: 'Selección inválida' }); } const cat = categorias.find(c => c.id === item.cat); if (!cat) { return res.status(400).json({ error: `Categoría "${item.cat}" no existe` }); } if (Array.isArray(item.sub) && item.sub.length > 0) { let nodo = cat; for (let i = 0; i < item.sub.length; i++) { const subs = Array.isArray(nodo.subcategorias) ? nodo.subcategorias : []; const sig = subs.find(s => s.id === item.sub[i]); if (!sig) { return res.status(400).json({ error: `Subcategoría "${item.sub[i]}" no encontrada` }); } nodo = sig; } } } // Filtrar productos (misma lógica que PDF) const productosFiltrados = productos.filter(p => { if (!p.cat || p.cat === SIN_CATEGORIA_ID) return false; if (!p.img) return false; if (p.img.startsWith('agotados/')) return false; return seleccion.some(sel => { if (p.cat !== sel.cat) return false; const pSub = Array.isArray(p.sub) ? p.sub : []; const selSub = Array.isArray(sel.sub) ? sel.sub : []; if (selSub.length === 0) return true; if (pSub.length < selSub.length) return false; for (let i = 0; i < selSub.length; i++) { if (pSub[i] !== selSub[i]) return false; } return true; }); }); if (productosFiltrados.length === 0) { return res.status(400).json({ error: 'No hay productos disponibles en las hojas seleccionadas' }); } // Stream ZIP const filename = `fotos-infinity-${Date.now()}.zip`; res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); const archive = archiver('zip', { zlib: { level: 5 } }); archive.on('error', err => { console.error('ZIP error:', err.message); if (!res.headersSent) { res.status(500).json({ error: 'Error al generar el ZIP: ' + err.message }); } else { res.end(); } }); archive.pipe(res); for (const p of productosFiltrados) { const imgRelPath = p.img; const imgAbsPath = path.join(IMG_DIR, imgRelPath); const ext = path.extname(imgRelPath) || '.webp'; const arcName = `${p.ref || 'producto'}${ext}`; try { await fs.access(imgAbsPath); archive.file(imgAbsPath, { name: arcName }); } catch { console.warn(`[fotos] Imagen no encontrada: ${imgAbsPath}`); } } await archive.finalize(); } catch (err) { console.error('Fotos ZIP generation error:', err); if (!res.headersSent) { res.status(500).json({ error: 'Error al generar el ZIP: ' + err.message }); } } }); // ════════════════════════════════ // VENDEDORES — gestión (admin / permiso pedidos) // ════════════════════════════════ const VENDEDORES_FILE = 'vendedores.json'; const PEDIDOS_META_FILE = 'pedidos-meta.json'; async function getVendedores() { try { return await readJson(VENDEDORES_FILE); } catch { return []; } } async function getPedidosMeta() { try { return await readJson(PEDIDOS_META_FILE); } catch { return []; } } app.get('/api/vendedores', async (req, res) => { const vendedores = await getVendedores(); res.json(vendedores); }); app.post('/api/vendedores', requirePedidosManagement, async (req, res) => { const { nombre } = req.body || {}; if (!nombre || !nombre.trim()) { return res.status(400).json({ error: 'El nombre del vendedor es obligatorio' }); } const vendedores = await getVendedores(); const id = String(Date.now()); vendedores.push({ id, nombre: nombre.trim() }); await writeJson(VENDEDORES_FILE, vendedores); res.json(vendedores); }); app.delete('/api/vendedores/:id', requirePedidosManagement, async (req, res) => { let vendedores = await getVendedores(); vendedores = vendedores.filter(v => v.id !== req.params.id); await writeJson(VENDEDORES_FILE, vendedores); res.json(vendedores); }); // ════════════════════════════════ // PEDIDOS — generar PDF, listar y eliminar metadatos // ════════════════════════════════ // Listar pedidos (admin / permiso pedidos) app.get('/api/pedidos', requirePedidosManagement, async (req, res) => { const { vendedor } = req.query; let pedidos = await getPedidosMeta(); if (vendedor) { pedidos = pedidos.filter(p => p.vendedor === vendedor); } res.json(pedidos); }); // Eliminar pedido (admin / permiso pedidos) app.delete('/api/pedidos/:filename', requirePedidosManagement, async (req, res) => { const { filename } = req.params; // Validar que el filename sea seguro (solo letras, números, guiones, puntos) if (!/^[\w.-]+\.pdf$/.test(filename)) { return res.status(400).json({ error: 'Nombre de archivo inválido' }); } try { // Eliminar archivo PDF const filepath = path.join(PEDIDOS_DIR, filename); try { await fs.unlink(filepath); } catch (err) { if (err.code !== 'ENOENT') throw err; // Si no existe el archivo, igual seguimos para limpiar metadatos } // Eliminar metadatos const pedidosMeta = await getPedidosMeta(); const nuevosMeta = pedidosMeta.filter(p => p.filename !== filename); if (nuevosMeta.length === pedidosMeta.length) { return res.status(404).json({ error: 'Pedido no encontrado' }); } await writeJson(PEDIDOS_META_FILE, nuevosMeta); res.json({ ok: true }); } catch (err) { console.error('Delete pedido error:', err); res.status(500).json({ error: 'Error al eliminar el pedido: ' + err.message }); } }); app.post('/api/pedidos/generar-pdf', async (req, res) => { const { items, cliente, vendedor } = req.body || {}; if (!Array.isArray(items) || items.length === 0) { return res.status(400).json({ error: 'El pedido debe tener al menos un producto' }); } if (!cliente || !cliente.nombre || !cliente.ciudad || !cliente.telefono || !cliente.direccion) { return res.status(400).json({ error: 'Todos los datos del cliente son obligatorios' }); } try { const filename = `pedido-${Date.now()}.pdf`; const filepath = path.join(PEDIDOS_DIR, filename); await fs.mkdir(PEDIDOS_DIR, { recursive: true }); const doc = new PDFDocument({ size: 'A4', margins: { top: 50, bottom: 50, left: 50, right: 50 }, info: { Title: `Pedido - ${cliente.nombre}`, Author: 'Infinity', Subject: 'Pedido de productos' } }); const writeStream = fsSync.createWriteStream(filepath); doc.pipe(writeStream); const pageW = doc.page.width; const MARGIN_X = 50; const pageH = doc.page.height; doc.fontSize(36).fillColor('#8b5cf6').font('Helvetica-Bold') .text('∞ Infinity', MARGIN_X, 50, { width: pageW - 2 * MARGIN_X }); doc.moveTo(MARGIN_X, 100).lineTo(pageW - MARGIN_X, 100) .strokeColor('#8b5cf6').lineWidth(1.5).stroke(); doc.fontSize(22).fillColor('#1a1a2e').font('Helvetica-Bold') .text('Pedido', MARGIN_X, 120, { width: pageW - 2 * MARGIN_X }); const fechaStr = new Date().toLocaleDateString('es-CO', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' }); doc.fontSize(10).fillColor('#8b8ba7').font('Helvetica') .text(fechaStr, MARGIN_X, 150, { width: pageW - 2 * MARGIN_X }); if (vendedor) { doc.fontSize(10).fillColor('#8b5cf6').font('Helvetica-Bold') .text(`Vendedor: ${vendedor}`, MARGIN_X, 168, { width: pageW - 2 * MARGIN_X }); } doc.fontSize(14).fillColor('#1a1a2e').font('Helvetica-Bold') .text('Datos del cliente', MARGIN_X, vendedor ? 192 : 185, { width: pageW - 2 * MARGIN_X }); const clientY0 = vendedor ? 217 : 210; const clientFields = [ ['Nombre', cliente.nombre], ['Ciudad', cliente.ciudad], ['Teléfono', cliente.telefono], ['Dirección', cliente.direccion] ]; let clientY = clientY0; doc.fontSize(11).fillColor('#4a4a68').font('Helvetica'); for (const [label, value] of clientFields) { doc.text(`${label}: `, MARGIN_X, clientY, { continued: true }); doc.fillColor('#1a1a2e').text(value, { width: pageW - 2 * MARGIN_X }); clientY += 18; } let tableY = clientY + 20; doc.moveTo(MARGIN_X, tableY).lineTo(pageW - MARGIN_X, tableY) .strokeColor('#e8e6f0').lineWidth(0.5).stroke(); tableY += 12; const colRef = MARGIN_X; const colNombre = MARGIN_X + 80; const colCant = pageW - MARGIN_X - 180; const colPrecio = pageW - MARGIN_X - 120; const colSubtotal = pageW - MARGIN_X - 60; doc.fontSize(9).fillColor('#8b8ba7').font('Helvetica-Bold'); doc.text('REF', colRef, tableY); doc.text('Producto', colNombre, tableY); doc.text('Cant', colCant, tableY, { align: 'center', width: 40 }); doc.text('Precio', colPrecio, tableY, { align: 'right', width: 60 }); doc.text('Total', colSubtotal, tableY, { align: 'right', width: 60 }); let totalGeneral = 0; for (const item of items) { const subtotal = (item.precio || 0) * (item.cantidad || 1); totalGeneral += subtotal; tableY += 22; if (tableY > pageH - 90) { doc.addPage(); tableY = 60; } doc.fontSize(9).fillColor('#8b8ba7').font('Helvetica') .text(String(item.ref || '—'), colRef, tableY, { width: 76 }); doc.fillColor('#1a1a2e').font('Helvetica') .text(String(item.nombre || item.ref || '—'), colNombre, tableY, { width: colCant - colNombre - 10 }); doc.fillColor('#1a1a2e').font('Helvetica-Bold') .text(String(item.cantidad || 1), colCant, tableY, { align: 'center', width: 40 }); doc.fillColor('#4a4a68').font('Helvetica') .text(`$${(item.precio || 0).toLocaleString('es-CO')}`, colPrecio, tableY, { align: 'right', width: 60 }); doc.fillColor('#8b5cf6').font('Helvetica-Bold') .text(`$${subtotal.toLocaleString('es-CO')}`, colSubtotal, tableY, { align: 'right', width: 60 }); } tableY += 20; doc.moveTo(MARGIN_X, tableY).lineTo(pageW - MARGIN_X, tableY) .strokeColor('#e8e6f0').lineWidth(0.5).stroke(); tableY += 14; doc.fontSize(11).fillColor('#4a4a68').font('Helvetica'); doc.text('Total pedido:', MARGIN_X, tableY, { continued: true }); doc.fontSize(16).fillColor('#8b5cf6').font('Helvetica-Bold') .text(` $${totalGeneral.toLocaleString('es-CO')}`, { width: pageW - 2 * MARGIN_X }); const range = doc.bufferedPageRange(); for (let i = range.start; i < range.start + range.count; i++) { doc.switchToPage(i); doc.fontSize(8).fillColor('#8b8ba7').font('Helvetica') .text(`${i + 1} / ${range.count}`, MARGIN_X, pageH - 40, { width: pageW - 2 * MARGIN_X, align: 'center' }); } doc.end(); await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); }); // Guardar metadatos del pedido const pedidosMeta = await getPedidosMeta(); pedidosMeta.push({ filename, fecha: new Date().toISOString(), cliente, items: items.map(i => ({ ref: i.ref, nombre: i.nombre, precio: i.precio, cantidad: i.cantidad })), vendedor: vendedor || null, total: totalGeneral }); await writeJson(PEDIDOS_META_FILE, pedidosMeta); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.sendFile(filepath); } catch (err) { console.error('Pedido PDF error:', err); res.status(500).json({ error: 'Error al generar el pedido: ' + err.message }); } }); // ════════════════════════════════ // REDES SOCIALES — gestión de publicaciones embebidas // ════════════════════════════════ const REDES_FILE = 'redes.json'; async function getRedes() { try { return await readJson(REDES_FILE); } catch { return []; } } function extraerPlataforma(url) { if (/instagram\.com/i.test(url)) return 'instagram'; if (/facebook\.com/i.test(url)) return 'facebook'; return null; } // Público: solo activas app.get('/api/redes', async (req, res) => { const todas = await getRedes(); res.json(todas.filter(r => r.activo).sort((a, b) => a.orden - b.orden)); }); // Admin: todas app.get('/api/redes/admin', requireAdmin, async (req, res) => { const todas = await getRedes(); res.json(todas.sort((a, b) => a.orden - b.orden)); }); app.post('/api/redes', requireAdmin, async (req, res) => { const { url } = req.body || {}; if (!url || !url.trim()) { return res.status(400).json({ error: 'La URL es obligatoria' }); } const plataforma = extraerPlataforma(url); if (!plataforma) { return res.status(400).json({ error: 'La URL debe ser de Instagram o Facebook' }); } const todas = await getRedes(); const nueva = { id: String(Date.now()), url: url.trim(), plataforma, activo: true, orden: todas.length }; todas.push(nueva); await writeJson(REDES_FILE, todas); res.json(todas); }); app.delete('/api/redes/:id', requireAdmin, async (req, res) => { let todas = await getRedes(); todas = todas.filter(r => r.id !== req.params.id); await writeJson(REDES_FILE, todas); res.json(todas); }); app.put('/api/redes/:id', requireAdmin, async (req, res) => { const todas = await getRedes(); const idx = todas.findIndex(r => r.id === req.params.id); if (idx === -1) return res.status(404).json({ error: 'No encontrada' }); const { activo } = req.body || {}; if (typeof activo === 'boolean') todas[idx].activo = activo; await writeJson(REDES_FILE, todas); res.json(todas); }); // ════════════════════════════════ // VIDEOS — gestión de videos subidos // ════════════════════════════════ const MAX_VIDEOS = 10; const videoUpload = multer({ storage: multer.diskStorage({ destination: VIDEOS_DIR, filename: (req, file, cb) => { const ext = path.extname(file.originalname) || '.mp4'; cb(null, `video-${Date.now()}${ext}`); } }), limits: { fileSize: 200 * 1024 * 1024 } // 200MB }); app.get('/api/videos', async (req, res) => { try { await fs.mkdir(VIDEOS_DIR, { recursive: true }); const files = await fs.readdir(VIDEOS_DIR); const videos = files .filter(f => f.startsWith('video-')) .sort() .reverse() .map(f => ({ filename: f, url: `/videos/${f}` })); res.json(videos); } catch (err) { console.error('Error listing videos:', err); res.status(500).json({ error: 'Error al listar videos' }); } }); app.post('/api/videos', requireAdmin, videoUpload.single('video'), async (req, res) => { try { await fs.mkdir(VIDEOS_DIR, { recursive: true }); const files = await fs.readdir(VIDEOS_DIR); const videoFiles = files.filter(f => f.startsWith('video-')); if (videoFiles.length > MAX_VIDEOS) { // Eliminar el que se subió justo ahora si excede if (req.file) await fs.unlink(req.file.path).catch(() => {}); return res.status(400).json({ error: `Máximo ${MAX_VIDEOS} videos. Elimina uno antes de subir otro.` }); } const videos = videoFiles.sort().reverse().map(f => ({ filename: f, url: `/videos/${f}` })); res.json(videos); } catch (err) { console.error('Error uploading video:', err); if (req.file) await fs.unlink(req.file.path).catch(() => {}); res.status(500).json({ error: 'Error al subir video' }); } }); app.delete('/api/videos/:filename', requireAdmin, async (req, res) => { const { filename } = req.params; if (!filename.startsWith('video-')) { return res.status(400).json({ error: 'Nombre de archivo inválido' }); } try { await fs.unlink(path.join(VIDEOS_DIR, filename)); res.json({ ok: true }); } catch (err) { if (err.code === 'ENOENT') return res.status(404).json({ error: 'Video no encontrado' }); console.error('Error deleting video:', err); res.status(500).json({ error: 'Error al eliminar video' }); } }); // Proxy para embeds de redes sociales (evita bloqueo de terceros en móviles) app.get('/api/redes/proxy', async (req, res) => { const { url } = req.query; if (!url) return res.status(400).json({ error: 'Falta url' }); let embedUrl; if (url.includes('instagram.com/p/')) { const match = url.match(/instagram\.com\/p\/([^/?]+)/); if (!match) return res.status(400).json({ error: 'URL de Instagram inválida' }); embedUrl = `https://www.instagram.com/p/${match[1]}/embed`; } else if (url.includes('facebook.com')) { embedUrl = `https://www.facebook.com/plugins/post.php?href=${encodeURIComponent(url)}&show_text=true`; } else { return res.status(400).json({ error: 'Solo se admiten URLs de Instagram y Facebook' }); } try { const response = await fetch(embedUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; InfinityBot)' } }); const html = await response.text(); res.set('Content-Type', 'text/html; charset=utf-8'); res.set('X-Frame-Options', ''); res.send(html); } catch (err) { console.error('Redes proxy error:', err); res.status(502).send('Error al cargar el contenido'); } }); // ════════════════════════════════ // START // ════════════════════════════════ app.listen(PORT, () => { console.log(`∞ Infinity server corriendo en http://localhost:${PORT}`); scheduleSessionCleanup(); });