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