Solo items y usuarios

This commit is contained in:
2026-06-05 22:06:52 -05:00
commit aa5e85ae94
23 changed files with 6152 additions and 0 deletions
+939
View File
@@ -0,0 +1,939 @@
/* ════════════════════════════════
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á "<ref>.webp")
// Devuelve la nueva ruta relativa ("<newCat>/<ref>.webp").
// Si la imagen origen no existe, no falla: igual devuelve la nueva ruta
// (útil para entradas huérfanas en el JSON).
async function moveProductImage(oldImg, newCat, ref) {
const newName = `${ref}.webp`;
const newPath = path.join(IMG_DIR, newCat, newName);
// Crear carpeta destino si no existe
await fs.mkdir(path.join(IMG_DIR, newCat), { recursive: true });
if (oldImg) {
const oldPath = path.join(IMG_DIR, oldImg);
try {
await fs.rename(oldPath, newPath);
} catch (err) {
if (err.code === 'ENOENT') {
// Imagen origen no existe: no es error (entrada huérfana).
// Igual devolvemos la nueva ruta; el JSON quedará coherente.
} else {
// Cross-device u otro error: copiar y borrar.
try {
await fs.copyFile(oldPath, newPath);
await fs.unlink(oldPath);
} catch (err2) {
if (err2.code !== 'ENOENT') throw err2;
}
}
}
}
return `${newCat}/${newName}`;
}
// Helper: borrar la imagen de un producto (best-effort).
async function deleteProductImage(img) {
if (!img) return;
const imgPath = path.join(IMG_DIR, img);
try {
await fs.unlink(imgPath);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
}
// ════════════════════════════════
// MIDDLEWARES DE AUTENTICACIÓN
// ════════════════════════════════
async function requireAuth(req, res, next) {
const session = await getSessionUser(req);
if (!session) return res.status(401).json({ error: 'No autorizado' });
req.user = session;
next();
}
async function requireAdmin(req, res, next) {
const session = await getSessionUser(req);
if (!session) return res.status(401).json({ error: 'No autorizado' });
const user = await getUserData(session.usuario);
if (!user) return res.status(401).json({ error: 'No autorizado' });
if (user.rol !== 'admin') {
return res.status(403).json({ error: 'Se requiere rol de administrador' });
}
req.user = user;
next();
}
// Permite acceder si es admin O tiene el permiso 'usuarios'
async function requireUserManagement(req, res, next) {
const session = await getSessionUser(req);
if (!session) return res.status(401).json({ error: 'No autorizado' });
const user = await getUserData(session.usuario);
if (!user) return res.status(401).json({ error: 'No autorizado' });
const esAdmin = user.rol === 'admin';
const tienePermiso = (user.permisos || []).includes('usuarios');
if (!esAdmin && !tienePermiso) {
return res.status(403).json({ error: 'No tienes permiso para gestionar usuarios' });
}
req.user = user;
next();
}
// Permite acceder si es admin O tiene el permiso 'categorias'
async function requireCategoriasManagement(req, res, next) {
const session = await getSessionUser(req);
if (!session) return res.status(401).json({ error: 'No autorizado' });
const user = await getUserData(session.usuario);
if (!user) return res.status(401).json({ error: 'No autorizado' });
const esAdmin = user.rol === 'admin';
const tienePermiso = (user.permisos || []).includes('categorias');
if (!esAdmin && !tienePermiso) {
return res.status(403).json({ error: 'No tienes permiso para gestionar categorías' });
}
req.user = user;
next();
}
// Permite acceder si es admin O tiene el permiso 'productos'
async function requireProductosManagement(req, res, next) {
const session = await getSessionUser(req);
if (!session) return res.status(401).json({ error: 'No autorizado' });
const user = await getUserData(session.usuario);
if (!user) return res.status(401).json({ error: 'No autorizado' });
const esAdmin = user.rol === 'admin';
const tienePermiso = (user.permisos || []).includes('productos');
if (!esAdmin && !tienePermiso) {
return res.status(403).json({ error: 'No tienes permiso para gestionar productos' });
}
req.user = user;
next();
}
// ════════════════════════════════
// RUTAS DE AUTENTICACIÓN
// ════════════════════════════════
app.post('/api/auth/login', async (req, res) => {
const { usuario, contraseña } = req.body || {};
if (!usuario || !contraseña) {
return res.status(400).json({ error: 'Usuario y contraseña requeridos' });
}
try {
const data = await readJson('contraseñas.json');
const usuarios = data.usuarios || [];
const match = usuarios.find(u => u.usuario === usuario && u.contraseña === contraseña);
if (!match) return res.status(401).json({ error: 'Credenciales inválidas' });
const token = await createSession(match);
res.cookie('session', token, {
signed: true,
httpOnly: true,
sameSite: 'lax',
secure: false, // ⚠️ cambiar a true cuando se use HTTPS
maxAge: 24 * 60 * 60 * 1000
});
res.json({ ok: true, usuario: match.usuario, rol: match.rol });
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Error en el servidor' });
}
});
app.post('/api/auth/logout', async (req, res) => {
const token = req.signedCookies.session;
if (token) await destroySession(token);
res.clearCookie('session');
res.json({ ok: true });
});
app.get('/api/auth/me', async (req, res) => {
const session = await getSessionUser(req);
if (!session) return res.status(401).json({ error: 'No autenticado' });
const user = await getUserData(session.usuario);
if (!user) return res.status(401).json({ error: 'Usuario no encontrado' });
res.json({
usuario: user.usuario,
rol: user.rol,
permisos: user.permisos || []
});
});
app.put('/api/auth/password', requireAdmin, async (req, res) => {
const { actual, nueva } = req.body || {};
if (!actual || !nueva) {
return res.status(400).json({ error: 'Contraseña actual y nueva requeridas' });
}
if (nueva.length < 6) {
return res.status(400).json({ error: 'La nueva contraseña debe tener al menos 6 caracteres' });
}
try {
const data = await readJson('contraseñas.json');
const usuarios = data.usuarios || [];
const idx = usuarios.findIndex(u => u.usuario === req.user.usuario);
if (idx === -1) return res.status(404).json({ error: 'Usuario no encontrado' });
if (usuarios[idx].contraseña !== actual) {
return res.status(401).json({ error: 'La contraseña actual es incorrecta' });
}
usuarios[idx].contraseña = nueva;
await writeJson('contraseñas.json', { usuarios });
res.json({ ok: true });
} catch (err) {
console.error('Change password error:', err);
res.status(500).json({ error: 'Error al cambiar contraseña' });
}
});
// ════════════════════════════════
// RUTAS PÚBLICAS (catálogo)
// ════════════════════════════════
app.get('/api/categorias', async (req, res) => {
try {
await ensureSinCategoria();
const categorias = await getCategorias();
res.json(categorias);
} catch {
res.json([]);
}
});
app.get('/api/productos', async (req, res) => {
try {
const data = await readJson('productos.json');
res.json(data);
} catch {
res.json([]);
}
});
// ════════════════════════════════
// GESTIÓN DE CATEGORÍAS (admin o permiso 'categorias')
// ════════════════════════════════
app.post('/api/categorias', requireCategoriasManagement, async (req, res) => {
const { nombre, icono } = req.body || {};
if (!nombre || !nombre.trim()) {
return res.status(400).json({ error: 'El nombre es obligatorio' });
}
const id = slugify(nombre);
if (!id) {
return res.status(400).json({ error: 'El nombre genera un ID inválido' });
}
try {
const categorias = await getCategorias();
const nombreNorm = nombre.trim().toLowerCase();
// No permitir duplicados por id NI por nombre
if (categorias.some(c => c.id === id)) {
return res.status(409).json({ error: 'Ya existe una categoría con ese nombre' });
}
if (categorias.some(c => c.nombre.toLowerCase() === nombreNorm)) {
return res.status(409).json({ error: 'Ya existe una categoría con ese nombre' });
}
// Crear carpeta física
const dir = path.join(IMG_DIR, id);
try {
await fs.access(dir);
// Si ya existe la carpeta, no es error — solo se agrega al JSON
} catch {
await fs.mkdir(dir, { recursive: true });
}
const nuevaCat = { id, nombre: nombre.trim(), icono: icono || '📦' };
categorias.push(nuevaCat);
await saveCategorias(categorias);
res.json({ ok: true, categoria: nuevaCat });
} catch (err) {
console.error('Create category error:', err);
res.status(500).json({ error: 'Error al crear la categoría' });
}
});
app.put('/api/categorias/:id', requireCategoriasManagement, async (req, res) => {
const targetId = req.params.id;
const { nombre, icono } = req.body || {};
if (!nombre || !nombre.trim()) {
return res.status(400).json({ error: 'El nombre es obligatorio' });
}
try {
const categorias = await getCategorias();
const idx = categorias.findIndex(c => c.id === targetId);
if (idx === -1) return res.status(404).json({ error: 'Categoría no encontrada' });
if (targetId === SIN_CATEGORIA_ID) {
return res.status(400).json({ error: 'No se puede editar la categoría del sistema' });
}
const nombreNorm = nombre.trim().toLowerCase();
// No permitir que el nuevo nombre coincida con OTRA categoría
if (categorias.some((c, i) => i !== idx && c.nombre.toLowerCase() === nombreNorm)) {
return res.status(409).json({ error: 'Ya existe otra categoría con ese nombre' });
}
// El id NO se modifica (es inmutable). Solo nombre e icono.
categorias[idx].nombre = nombre.trim();
if (icono) categorias[idx].icono = icono;
await saveCategorias(categorias);
res.json({ ok: true, categoria: categorias[idx] });
} catch (err) {
console.error('Update category error:', err);
res.status(500).json({ error: 'Error al actualizar la categoría' });
}
});
app.delete('/api/categorias/:id', requireCategoriasManagement, async (req, res) => {
const targetId = req.params.id;
if (targetId === SIN_CATEGORIA_ID) {
return res.status(400).json({ error: 'No se puede eliminar la categoría del sistema' });
}
try {
const categorias = await getCategorias();
const idx = categorias.findIndex(c => c.id === targetId);
if (idx === -1) return res.status(404).json({ error: 'Categoría no encontrada' });
// Asegurar "sin-categoria" existe
await ensureSinCategoria();
// Contar productos en esta categoría
const productos = await getProductos();
const itemsAfectados = productos.filter(p => p.cat === targetId);
if (itemsAfectados.length > 0) {
// Mover imágenes de Imagenes/<targetId>/ → Imagenes/sin-categoria/
const srcDir = path.join(IMG_DIR, targetId);
const dstDir = path.join(IMG_DIR, SIN_CATEGORIA_ID);
try {
const files = await fs.readdir(srcDir);
for (const file of files) {
const srcFile = path.join(srcDir, file);
const dstFile = path.join(dstDir, file);
// Mover (rename). Si existe destino, sobreescribir (cada producto tiene img único).
try {
await fs.rename(srcFile, dstFile);
} catch (err) {
// Si falla el rename (p.ej. cross-device), copiar y borrar
await fs.copyFile(srcFile, dstFile);
await fs.unlink(srcFile);
}
}
// Borrar carpeta origen (vacía)
try {
await fs.rmdir(srcDir);
} catch {
// Si quedan subcarpetas u otros archivos, no fallar
}
} catch (err) {
// Si la carpeta no existe, no es error
if (err.code !== 'ENOENT') throw err;
}
// Actualizar productos: cat → sin-categoria, img → sin-categoria/...
for (const p of productos) {
if (p.cat === targetId) {
p.cat = SIN_CATEGORIA_ID;
if (p.img && p.img.startsWith(targetId + '/')) {
p.img = SIN_CATEGORIA_ID + '/' + p.img.slice(targetId.length + 1);
}
}
}
await saveProductos(productos);
} else {
// Sin items: solo borrar carpeta y entrada del JSON
const srcDir = path.join(IMG_DIR, targetId);
try {
const files = await fs.readdir(srcDir);
for (const file of files) {
await fs.unlink(path.join(srcDir, file));
}
await fs.rmdir(srcDir);
} catch (err) {
if (err.code !== 'ENOENT') console.error('Error borrando carpeta:', err);
}
}
// Quitar del JSON
categorias.splice(idx, 1);
await saveCategorias(categorias);
res.json({ ok: true, itemsMovidos: itemsAfectados.length });
} catch (err) {
console.error('Delete category error:', err);
res.status(500).json({ error: 'Error al eliminar la categoría' });
}
});
// ════════════════════════════════
// 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/<ref>.webp y actualiza el JSON.
// El producto sigue en el JSON (con su categoría original), pero su `img`
// pasa a apuntar a "agotados/<ref>.webp", que es la señal de "agotado".
app.post('/api/productos/:ref/agotar', requireProductosManagement, async (req, res) => {
const ref = req.params.ref;
try {
const productos = await getProductos();
const idx = productos.findIndex(p => p.ref === ref);
if (idx === -1) return res.status(404).json({ error: 'Producto no encontrado' });
if (productos[idx].img && productos[idx].img.startsWith('agotados/')) {
return res.status(400).json({ error: 'El producto ya está agotado' });
}
const oldImg = productos[idx].img;
const newImg = await moveProductImage(oldImg, 'agotados', ref);
productos[idx].img = newImg;
await saveProductos(productos);
res.json({ ok: true, ref, img: newImg });
} catch (err) {
console.error('Agotar error:', err);
res.status(500).json({ error: 'Error al marcar como agotado' });
}
});
// REACTIVAR un producto agotado (admin o permiso 'productos')
// Mueve la imagen de vuelta a su carpeta de categoría original
// (o a "sin-categoria" si la categoría ya no existe) y actualiza el JSON.
app.post('/api/productos/:ref/reactivar', requireProductosManagement, async (req, res) => {
const ref = req.params.ref;
try {
const productos = await getProductos();
const idx = productos.findIndex(p => p.ref === ref);
if (idx === -1) return res.status(404).json({ error: 'Producto no encontrado' });
if (!productos[idx].img || !productos[idx].img.startsWith('agotados/')) {
return res.status(400).json({ error: 'El producto no está agotado' });
}
// Categoría original (la guardamos en el JSON al subir)
const targetCat = productos[idx].cat;
if (!targetCat) {
return res.status(400).json({ error: 'El producto no tiene categoría original' });
}
// Si la categoría original ya no existe, fallback a "sin-categoria"
const categorias = await getCategorias();
let destCat = targetCat;
if (!categorias.some(c => c.id === targetCat)) {
await ensureSinCategoria();
destCat = SIN_CATEGORIA_ID;
productos[idx].cat = SIN_CATEGORIA_ID;
}
const newImg = await moveProductImage(productos[idx].img, destCat, ref);
productos[idx].img = newImg;
await saveProductos(productos);
res.json({ ok: true, ref, img: newImg, cat: destCat });
} catch (err) {
console.error('Reactivar error:', err);
res.status(500).json({ error: 'Error al reactivar el producto' });
}
});
// ELIMINAR DEFINITIVAMENTE un producto (admin o permiso 'productos')
// Borra la imagen del disco y la entrada del JSON.
// Funciona tanto para productos disponibles como para agotados.
app.delete('/api/productos/:ref', requireProductosManagement, async (req, res) => {
const ref = req.params.ref;
try {
const productos = await getProductos();
const idx = productos.findIndex(p => p.ref === ref);
if (idx === -1) return res.status(404).json({ error: 'Producto no encontrado' });
const img = productos[idx].img;
await deleteProductImage(img);
productos.splice(idx, 1);
await saveProductos(productos);
res.json({ ok: true, ref });
} catch (err) {
console.error('Delete producto error:', err);
res.status(500).json({ error: 'Error al eliminar el producto' });
}
});
// 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}`);
});