Files
CATALOGO_INFINITY/backend/server.js
T
2026-06-09 10:05:00 -05:00

2053 lines
74 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ════════════════════════════════
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');
// ════════════════════════════════
// 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));
// 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á "<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();
}
// 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/<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' });
}
});
// ════════════════════════════════
// 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' });
}
// 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;
}
await saveCategorias(categorias);
res.json({ ok: true });
} 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: <cat> o <cat>/<sub1> o <cat>/<sub1>/<sub2>
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/<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' });
}
});
// ════════════════════════════════
// 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 });
}
}
});
// ════════════════════════════════
// PEDIDOS — generar PDF con pedido
// ════════════════════════════════
app.post('/api/pedidos/generar-pdf', async (req, res) => {
const { items, cliente } = 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);
// Asegurar que la carpeta existe
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;
// === ENCABEZADO ===
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 });
// === DATOS DEL CLIENTE ===
doc.fontSize(14).fillColor('#1a1a2e').font('Helvetica-Bold')
.text('Datos del cliente', MARGIN_X, 185, { width: pageW - 2 * MARGIN_X });
const clientFields = [
['Nombre', cliente.nombre],
['Ciudad', cliente.ciudad],
['Teléfono', cliente.telefono],
['Dirección', cliente.direccion]
];
let clientY = 210;
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;
}
// === TABLA DE PRODUCTOS ===
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 });
const headerY = tableY;
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 });
}
// Línea final
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 });
// === FOOTER con número de página ===
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();
// Esperar a que termine de escribir
await new Promise((resolve, reject) => {
writeStream.on('finish', resolve);
writeStream.on('error', reject);
});
// Enviar el archivo
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 });
}
});
// ════════════════════════════════
// START
// ════════════════════════════════
app.listen(PORT, () => {
console.log(`∞ Infinity server corriendo en http://localhost:${PORT}`);
scheduleSessionCleanup();
});