/* ════════════════════════════════ 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 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'].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 recibe 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'].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' }); } }); // 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')); }); // ════════════════════════════════ // START // ════════════════════════════════ app.listen(PORT, () => { console.log(`∞ Infinity server corriendo en http://localhost:${PORT}`); });