Solo items y usuarios
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Instalar dependencias primero (mejor cache de capas)
|
||||
COPY backend/package*.json ./
|
||||
RUN npm install --production
|
||||
|
||||
# Copiar el código del backend
|
||||
COPY backend/ ./
|
||||
|
||||
# Copiar el frontend (servido como estático)
|
||||
COPY frontend/ ./frontend/
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "infinity-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend para Infinity - catálogo web con panel admin",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express": "^4.19.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"sharp": "^0.33.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user