1220 lines
42 KiB
JavaScript
1220 lines
42 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 app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
const SECRET = process.env.SESSION_SECRET || 'infinity-cambia-esta-clave-en-produccion';
|
|
|
|
const DATA_DIR = path.join(__dirname, 'data');
|
|
const FRONTEND_DIR = path.join(__dirname, 'frontend');
|
|
const IMG_DIR = path.join(__dirname, 'Imagenes');
|
|
|
|
// ════════════════════════════════
|
|
// MIDDLEWARES
|
|
// ════════════════════════════════
|
|
app.use(express.json());
|
|
app.use(cookieParser(SECRET));
|
|
app.use(express.static(FRONTEND_DIR));
|
|
app.use('/Imagenes', express.static(IMG_DIR, { maxAge: '7d' }));
|
|
|
|
// Evita el 404 de favicon.ico
|
|
app.get('/favicon.ico', (req, res) => res.status(204).end());
|
|
|
|
// ════════════════════════════════
|
|
// HELPERS
|
|
// ════════════════════════════════
|
|
async function readJson(filename) {
|
|
const filePath = path.join(DATA_DIR, filename);
|
|
const data = await fs.readFile(filePath, 'utf8');
|
|
return JSON.parse(data);
|
|
}
|
|
|
|
async function writeJson(filename, data) {
|
|
const filePath = path.join(DATA_DIR, filename);
|
|
await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
}
|
|
|
|
async function ensureSesiones() {
|
|
try {
|
|
await fs.access(path.join(DATA_DIR, 'sesiones.json'));
|
|
} catch {
|
|
await writeJson('sesiones.json', []);
|
|
}
|
|
}
|
|
|
|
async function getSesiones() {
|
|
await ensureSesiones();
|
|
return await readJson('sesiones.json');
|
|
}
|
|
|
|
async function saveSesiones(sesiones) {
|
|
await writeJson('sesiones.json', sesiones);
|
|
}
|
|
|
|
async function createSession(usuario) {
|
|
const sesiones = await getSesiones();
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
sesiones.push({
|
|
token,
|
|
usuario: usuario.usuario,
|
|
rol: usuario.rol,
|
|
createdAt: Date.now()
|
|
});
|
|
await saveSesiones(sesiones);
|
|
return token;
|
|
}
|
|
|
|
async function destroySession(token) {
|
|
const sesiones = await getSesiones();
|
|
const filtradas = sesiones.filter(s => s.token !== token);
|
|
await saveSesiones(filtradas);
|
|
}
|
|
|
|
async function getSessionUser(req) {
|
|
const token = req.signedCookies.session;
|
|
if (!token) return null;
|
|
const sesiones = await getSesiones();
|
|
return sesiones.find(s => s.token === token) || null;
|
|
}
|
|
|
|
async function getUserData(usuario) {
|
|
try {
|
|
const data = await readJson('contraseñas.json');
|
|
return (data.usuarios || []).find(u => u.usuario === usuario) || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════
|
|
// HELPERS — Categorías
|
|
// ════════════════════════════════
|
|
const SIN_CATEGORIA_ID = 'sin-categoria';
|
|
|
|
function slugify(text) {
|
|
return String(text)
|
|
.toLowerCase()
|
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // quitar acentos
|
|
.replace(/[^a-z0-9\s-]/g, '') // quitar chars especiales
|
|
.trim()
|
|
.replace(/\s+/g, '-') // espacios → guiones
|
|
.replace(/-+/g, '-') // colapsar guiones
|
|
.replace(/^-+|-+$/g, ''); // trim guiones
|
|
}
|
|
|
|
async function getCategorias() {
|
|
try {
|
|
return await readJson('categorias.json');
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function saveCategorias(categorias) {
|
|
await writeJson('categorias.json', categorias);
|
|
}
|
|
|
|
async function ensureSinCategoria() {
|
|
const categorias = await getCategorias();
|
|
if (!categorias.some(c => c.id === SIN_CATEGORIA_ID)) {
|
|
categorias.push({ id: SIN_CATEGORIA_ID, nombre: 'Sin categoría', icono: '📦' });
|
|
await saveCategorias(categorias);
|
|
}
|
|
// Asegurar carpeta física
|
|
const dir = path.join(IMG_DIR, SIN_CATEGORIA_ID);
|
|
try {
|
|
await fs.access(dir);
|
|
} catch {
|
|
await fs.mkdir(dir, { recursive: true });
|
|
}
|
|
}
|
|
|
|
async function getProductos() {
|
|
try {
|
|
return await readJson('productos.json');
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function saveProductos(productos) {
|
|
await writeJson('productos.json', productos);
|
|
}
|
|
|
|
// Helper: mover la imagen de un producto entre carpetas.
|
|
// - oldImg: ruta actual relativa a /Imagenes/ (ej: "bolsos/AF100-124.webp")
|
|
// - newCat: id de la categoría destino (o "agotados")
|
|
// - ref: referencia del producto (la imagen destino será "<ref>.webp")
|
|
// Devuelve la nueva ruta relativa ("<newCat>/<ref>.webp").
|
|
// Si la imagen origen no existe, no falla: igual devuelve la nueva ruta
|
|
// (útil para entradas huérfanas en el JSON).
|
|
async function moveProductImage(oldImg, newCat, ref) {
|
|
const newName = `${ref}.webp`;
|
|
const newPath = path.join(IMG_DIR, newCat, newName);
|
|
|
|
// Crear carpeta destino si no existe
|
|
await fs.mkdir(path.join(IMG_DIR, newCat), { recursive: true });
|
|
|
|
if (oldImg) {
|
|
const oldPath = path.join(IMG_DIR, oldImg);
|
|
try {
|
|
await fs.rename(oldPath, newPath);
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
// Imagen origen no existe: no es error (entrada huérfana).
|
|
// Igual devolvemos la nueva ruta; el JSON quedará coherente.
|
|
} else {
|
|
// Cross-device u otro error: copiar y borrar.
|
|
try {
|
|
await fs.copyFile(oldPath, newPath);
|
|
await fs.unlink(oldPath);
|
|
} catch (err2) {
|
|
if (err2.code !== 'ENOENT') throw err2;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return `${newCat}/${newName}`;
|
|
}
|
|
|
|
// Helper: borrar la imagen de un producto (best-effort).
|
|
async function deleteProductImage(img) {
|
|
if (!img) return;
|
|
const imgPath = path.join(IMG_DIR, img);
|
|
try {
|
|
await fs.unlink(imgPath);
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') throw err;
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════
|
|
// MIDDLEWARES DE AUTENTICACIÓN
|
|
// ════════════════════════════════
|
|
async function requireAuth(req, res, next) {
|
|
const session = await getSessionUser(req);
|
|
if (!session) return res.status(401).json({ error: 'No autorizado' });
|
|
req.user = session;
|
|
next();
|
|
}
|
|
|
|
async function requireAdmin(req, res, next) {
|
|
const session = await getSessionUser(req);
|
|
if (!session) return res.status(401).json({ error: 'No autorizado' });
|
|
|
|
const user = await getUserData(session.usuario);
|
|
if (!user) return res.status(401).json({ error: 'No autorizado' });
|
|
|
|
if (user.rol !== 'admin') {
|
|
return res.status(403).json({ error: 'Se requiere rol de administrador' });
|
|
}
|
|
req.user = user;
|
|
next();
|
|
}
|
|
|
|
// Permite acceder si es admin O tiene el permiso 'usuarios'
|
|
async function requireUserManagement(req, res, next) {
|
|
const session = await getSessionUser(req);
|
|
if (!session) return res.status(401).json({ error: 'No autorizado' });
|
|
|
|
const user = await getUserData(session.usuario);
|
|
if (!user) return res.status(401).json({ error: 'No autorizado' });
|
|
|
|
const esAdmin = user.rol === 'admin';
|
|
const tienePermiso = (user.permisos || []).includes('usuarios');
|
|
|
|
if (!esAdmin && !tienePermiso) {
|
|
return res.status(403).json({ error: 'No tienes permiso para gestionar usuarios' });
|
|
}
|
|
req.user = user;
|
|
next();
|
|
}
|
|
|
|
// Permite acceder si es admin O tiene el permiso 'categorias'
|
|
async function requireCategoriasManagement(req, res, next) {
|
|
const session = await getSessionUser(req);
|
|
if (!session) return res.status(401).json({ error: 'No autorizado' });
|
|
|
|
const user = await getUserData(session.usuario);
|
|
if (!user) return res.status(401).json({ error: 'No autorizado' });
|
|
|
|
const esAdmin = user.rol === 'admin';
|
|
const tienePermiso = (user.permisos || []).includes('categorias');
|
|
|
|
if (!esAdmin && !tienePermiso) {
|
|
return res.status(403).json({ error: 'No tienes permiso para gestionar categorías' });
|
|
}
|
|
req.user = user;
|
|
next();
|
|
}
|
|
|
|
// Permite acceder si es admin O tiene el permiso 'productos'
|
|
async function requireProductosManagement(req, res, next) {
|
|
const session = await getSessionUser(req);
|
|
if (!session) return res.status(401).json({ error: 'No autorizado' });
|
|
|
|
const user = await getUserData(session.usuario);
|
|
if (!user) return res.status(401).json({ error: 'No autorizado' });
|
|
|
|
const esAdmin = user.rol === 'admin';
|
|
const tienePermiso = (user.permisos || []).includes('productos');
|
|
|
|
if (!esAdmin && !tienePermiso) {
|
|
return res.status(403).json({ error: 'No tienes permiso para gestionar productos' });
|
|
}
|
|
req.user = user;
|
|
next();
|
|
}
|
|
|
|
// ════════════════════════════════
|
|
// RUTAS DE AUTENTICACIÓN
|
|
// ════════════════════════════════
|
|
app.post('/api/auth/login', async (req, res) => {
|
|
const { usuario, contraseña } = req.body || {};
|
|
if (!usuario || !contraseña) {
|
|
return res.status(400).json({ error: 'Usuario y contraseña requeridos' });
|
|
}
|
|
|
|
try {
|
|
const data = await readJson('contraseñas.json');
|
|
const usuarios = data.usuarios || [];
|
|
const match = usuarios.find(u => u.usuario === usuario && u.contraseña === contraseña);
|
|
|
|
if (!match) return res.status(401).json({ error: 'Credenciales inválidas' });
|
|
|
|
const token = await createSession(match);
|
|
res.cookie('session', token, {
|
|
signed: true,
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
secure: false, // ⚠️ cambiar a true cuando se use HTTPS
|
|
maxAge: 24 * 60 * 60 * 1000
|
|
});
|
|
res.json({ ok: true, usuario: match.usuario, rol: match.rol });
|
|
} catch (err) {
|
|
console.error('Login error:', err);
|
|
res.status(500).json({ error: 'Error en el servidor' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/auth/logout', async (req, res) => {
|
|
const token = req.signedCookies.session;
|
|
if (token) await destroySession(token);
|
|
res.clearCookie('session');
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.get('/api/auth/me', async (req, res) => {
|
|
const session = await getSessionUser(req);
|
|
if (!session) return res.status(401).json({ error: 'No autenticado' });
|
|
|
|
const user = await getUserData(session.usuario);
|
|
if (!user) return res.status(401).json({ error: 'Usuario no encontrado' });
|
|
|
|
res.json({
|
|
usuario: user.usuario,
|
|
rol: user.rol,
|
|
permisos: user.permisos || []
|
|
});
|
|
});
|
|
|
|
app.put('/api/auth/password', requireAdmin, async (req, res) => {
|
|
const { actual, nueva } = req.body || {};
|
|
if (!actual || !nueva) {
|
|
return res.status(400).json({ error: 'Contraseña actual y nueva requeridas' });
|
|
}
|
|
if (nueva.length < 6) {
|
|
return res.status(400).json({ error: 'La nueva contraseña debe tener al menos 6 caracteres' });
|
|
}
|
|
|
|
try {
|
|
const data = await readJson('contraseñas.json');
|
|
const usuarios = data.usuarios || [];
|
|
const idx = usuarios.findIndex(u => u.usuario === req.user.usuario);
|
|
|
|
if (idx === -1) return res.status(404).json({ error: 'Usuario no encontrado' });
|
|
if (usuarios[idx].contraseña !== actual) {
|
|
return res.status(401).json({ error: 'La contraseña actual es incorrecta' });
|
|
}
|
|
|
|
usuarios[idx].contraseña = nueva;
|
|
await writeJson('contraseñas.json', { usuarios });
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error('Change password error:', err);
|
|
res.status(500).json({ error: 'Error al cambiar contraseña' });
|
|
}
|
|
});
|
|
|
|
// ════════════════════════════════
|
|
// RUTAS PÚBLICAS (catálogo)
|
|
// ════════════════════════════════
|
|
app.get('/api/categorias', async (req, res) => {
|
|
try {
|
|
await ensureSinCategoria();
|
|
const categorias = await getCategorias();
|
|
res.json(categorias);
|
|
} catch {
|
|
res.json([]);
|
|
}
|
|
});
|
|
|
|
app.get('/api/productos', async (req, res) => {
|
|
try {
|
|
const data = await readJson('productos.json');
|
|
res.json(data);
|
|
} catch {
|
|
res.json([]);
|
|
}
|
|
});
|
|
|
|
// ════════════════════════════════
|
|
// GESTIÓN DE CATEGORÍAS (admin o permiso 'categorias')
|
|
// ════════════════════════════════
|
|
app.post('/api/categorias', requireCategoriasManagement, async (req, res) => {
|
|
const { nombre, icono } = req.body || {};
|
|
|
|
if (!nombre || !nombre.trim()) {
|
|
return res.status(400).json({ error: 'El nombre es obligatorio' });
|
|
}
|
|
|
|
const id = slugify(nombre);
|
|
if (!id) {
|
|
return res.status(400).json({ error: 'El nombre genera un ID inválido' });
|
|
}
|
|
|
|
try {
|
|
const categorias = await getCategorias();
|
|
const nombreNorm = nombre.trim().toLowerCase();
|
|
|
|
// No permitir duplicados por id NI por nombre
|
|
if (categorias.some(c => c.id === id)) {
|
|
return res.status(409).json({ error: 'Ya existe una categoría con ese nombre' });
|
|
}
|
|
if (categorias.some(c => c.nombre.toLowerCase() === nombreNorm)) {
|
|
return res.status(409).json({ error: 'Ya existe una categoría con ese nombre' });
|
|
}
|
|
|
|
// Crear carpeta física
|
|
const dir = path.join(IMG_DIR, id);
|
|
try {
|
|
await fs.access(dir);
|
|
// Si ya existe la carpeta, no es error — solo se agrega al JSON
|
|
} catch {
|
|
await fs.mkdir(dir, { recursive: true });
|
|
}
|
|
|
|
const nuevaCat = { id, nombre: nombre.trim(), icono: icono || '📦' };
|
|
categorias.push(nuevaCat);
|
|
await saveCategorias(categorias);
|
|
|
|
res.json({ ok: true, categoria: nuevaCat });
|
|
} catch (err) {
|
|
console.error('Create category error:', err);
|
|
res.status(500).json({ error: 'Error al crear la categoría' });
|
|
}
|
|
});
|
|
|
|
app.put('/api/categorias/:id', requireCategoriasManagement, async (req, res) => {
|
|
const targetId = req.params.id;
|
|
const { nombre, icono } = req.body || {};
|
|
|
|
if (!nombre || !nombre.trim()) {
|
|
return res.status(400).json({ error: 'El nombre es obligatorio' });
|
|
}
|
|
|
|
try {
|
|
const categorias = await getCategorias();
|
|
const idx = categorias.findIndex(c => c.id === targetId);
|
|
|
|
if (idx === -1) return res.status(404).json({ error: 'Categoría no encontrada' });
|
|
|
|
if (targetId === SIN_CATEGORIA_ID) {
|
|
return res.status(400).json({ error: 'No se puede editar la categoría del sistema' });
|
|
}
|
|
|
|
const nombreNorm = nombre.trim().toLowerCase();
|
|
// No permitir que el nuevo nombre coincida con OTRA categoría
|
|
if (categorias.some((c, i) => i !== idx && c.nombre.toLowerCase() === nombreNorm)) {
|
|
return res.status(409).json({ error: 'Ya existe otra categoría con ese nombre' });
|
|
}
|
|
|
|
// El id NO se modifica (es inmutable). Solo nombre e icono.
|
|
categorias[idx].nombre = nombre.trim();
|
|
if (icono) categorias[idx].icono = icono;
|
|
|
|
await saveCategorias(categorias);
|
|
res.json({ ok: true, categoria: categorias[idx] });
|
|
} catch (err) {
|
|
console.error('Update category error:', err);
|
|
res.status(500).json({ error: 'Error al actualizar la categoría' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/categorias/:id', requireCategoriasManagement, async (req, res) => {
|
|
const targetId = req.params.id;
|
|
|
|
if (targetId === SIN_CATEGORIA_ID) {
|
|
return res.status(400).json({ error: 'No se puede eliminar la categoría del sistema' });
|
|
}
|
|
|
|
try {
|
|
const categorias = await getCategorias();
|
|
const idx = categorias.findIndex(c => c.id === targetId);
|
|
|
|
if (idx === -1) return res.status(404).json({ error: 'Categoría no encontrada' });
|
|
|
|
// Asegurar "sin-categoria" existe
|
|
await ensureSinCategoria();
|
|
|
|
// Contar productos en esta categoría
|
|
const productos = await getProductos();
|
|
const itemsAfectados = productos.filter(p => p.cat === targetId);
|
|
|
|
if (itemsAfectados.length > 0) {
|
|
// Mover imágenes de Imagenes/<targetId>/ → Imagenes/sin-categoria/
|
|
const srcDir = path.join(IMG_DIR, targetId);
|
|
const dstDir = path.join(IMG_DIR, SIN_CATEGORIA_ID);
|
|
|
|
try {
|
|
const files = await fs.readdir(srcDir);
|
|
for (const file of files) {
|
|
const srcFile = path.join(srcDir, file);
|
|
const dstFile = path.join(dstDir, file);
|
|
// Mover (rename). Si existe destino, sobreescribir (cada producto tiene img único).
|
|
try {
|
|
await fs.rename(srcFile, dstFile);
|
|
} catch (err) {
|
|
// Si falla el rename (p.ej. cross-device), copiar y borrar
|
|
await fs.copyFile(srcFile, dstFile);
|
|
await fs.unlink(srcFile);
|
|
}
|
|
}
|
|
// Borrar carpeta origen (vacía)
|
|
try {
|
|
await fs.rmdir(srcDir);
|
|
} catch {
|
|
// Si quedan subcarpetas u otros archivos, no fallar
|
|
}
|
|
} catch (err) {
|
|
// Si la carpeta no existe, no es error
|
|
if (err.code !== 'ENOENT') throw err;
|
|
}
|
|
|
|
// Actualizar productos: cat → sin-categoria, img → sin-categoria/...
|
|
for (const p of productos) {
|
|
if (p.cat === targetId) {
|
|
p.cat = SIN_CATEGORIA_ID;
|
|
if (p.img && p.img.startsWith(targetId + '/')) {
|
|
p.img = SIN_CATEGORIA_ID + '/' + p.img.slice(targetId.length + 1);
|
|
}
|
|
}
|
|
}
|
|
await saveProductos(productos);
|
|
} else {
|
|
// Sin items: solo borrar carpeta y entrada del JSON
|
|
const srcDir = path.join(IMG_DIR, targetId);
|
|
try {
|
|
const files = await fs.readdir(srcDir);
|
|
for (const file of files) {
|
|
await fs.unlink(path.join(srcDir, file));
|
|
}
|
|
await fs.rmdir(srcDir);
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') console.error('Error borrando carpeta:', err);
|
|
}
|
|
}
|
|
|
|
// Quitar del JSON
|
|
categorias.splice(idx, 1);
|
|
await saveCategorias(categorias);
|
|
|
|
res.json({ ok: true, itemsMovidos: itemsAfectados.length });
|
|
} catch (err) {
|
|
console.error('Delete category error:', err);
|
|
res.status(500).json({ error: 'Error al eliminar la categoría' });
|
|
}
|
|
});
|
|
|
|
// ════════════════════════════════
|
|
// GESTIÓN DE USUARIOS (admin o permiso 'usuarios')
|
|
// ════════════════════════════════
|
|
app.get('/api/usuarios', requireUserManagement, async (req, res) => {
|
|
try {
|
|
const data = await readJson('contraseñas.json');
|
|
const usuarios = (data.usuarios || []).map(u => ({
|
|
usuario: u.usuario,
|
|
rol: u.rol,
|
|
permisos: u.permisos || []
|
|
}));
|
|
res.json(usuarios);
|
|
} catch (err) {
|
|
console.error('List users error:', err);
|
|
res.status(500).json({ error: 'Error al cargar usuarios' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/usuarios', requireUserManagement, async (req, res) => {
|
|
const { usuario, contraseña, rol, permisos } = req.body || {};
|
|
|
|
if (!usuario || !contraseña || !rol) {
|
|
return res.status(400).json({ error: 'Todos los campos son obligatorios' });
|
|
}
|
|
if (contraseña.length < 6) {
|
|
return res.status(400).json({ error: 'La contraseña debe tener al menos 6 caracteres' });
|
|
}
|
|
if (!['admin', 'editor'].includes(rol)) {
|
|
return res.status(400).json({ error: 'Rol inválido' });
|
|
}
|
|
|
|
try {
|
|
const data = await readJson('contraseñas.json');
|
|
const usuarios = data.usuarios || [];
|
|
|
|
if (usuarios.some(u => u.usuario === usuario)) {
|
|
return res.status(409).json({ error: 'Ya existe un usuario con ese nombre' });
|
|
}
|
|
|
|
// Admin siempre tiene todos los permisos; editor recibe la lista enviada
|
|
const permisosFinal = rol === 'admin'
|
|
? []
|
|
: (Array.isArray(permisos) ? permisos : []);
|
|
|
|
usuarios.push({ usuario, contraseña, rol, permisos: permisosFinal });
|
|
await writeJson('contraseñas.json', { usuarios });
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error('Create user error:', err);
|
|
res.status(500).json({ error: 'Error al crear usuario' });
|
|
}
|
|
});
|
|
|
|
app.put('/api/usuarios/:usuario', requireUserManagement, async (req, res) => {
|
|
const targetUsuario = req.params.usuario;
|
|
const { contraseña, rol, permisos } = req.body || {};
|
|
|
|
if (rol && !['admin', 'editor'].includes(rol)) {
|
|
return res.status(400).json({ error: 'Rol inválido' });
|
|
}
|
|
if (contraseña && contraseña.length < 6) {
|
|
return res.status(400).json({ error: 'La contraseña debe tener al menos 6 caracteres' });
|
|
}
|
|
|
|
try {
|
|
const data = await readJson('contraseñas.json');
|
|
const usuarios = data.usuarios || [];
|
|
const idx = usuarios.findIndex(u => u.usuario === targetUsuario);
|
|
|
|
if (idx === -1) return res.status(404).json({ error: 'Usuario no encontrado' });
|
|
|
|
if (rol && rol !== 'admin' && usuarios[idx].rol === 'admin') {
|
|
const admins = usuarios.filter(u => u.rol === 'admin');
|
|
if (admins.length === 1) {
|
|
return res.status(400).json({ error: 'No puedes degradar al único administrador' });
|
|
}
|
|
}
|
|
|
|
if (contraseña) usuarios[idx].contraseña = contraseña;
|
|
if (rol) usuarios[idx].rol = rol;
|
|
|
|
// Si cambia a admin, limpiamos permisos (admin siempre tiene todos)
|
|
if (rol === 'admin') {
|
|
usuarios[idx].permisos = [];
|
|
} else if (Array.isArray(permisos)) {
|
|
usuarios[idx].permisos = permisos;
|
|
}
|
|
|
|
await writeJson('contraseñas.json', { usuarios });
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error('Update user error:', err);
|
|
res.status(500).json({ error: 'Error al actualizar usuario' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/usuarios/:usuario', requireUserManagement, async (req, res) => {
|
|
const targetUsuario = req.params.usuario;
|
|
|
|
if (targetUsuario === req.user.usuario) {
|
|
return res.status(400).json({ error: 'No puedes eliminarte a ti mismo' });
|
|
}
|
|
|
|
try {
|
|
const data = await readJson('contraseñas.json');
|
|
const usuarios = data.usuarios || [];
|
|
const target = usuarios.find(u => u.usuario === targetUsuario);
|
|
|
|
if (!target) return res.status(404).json({ error: 'Usuario no encontrado' });
|
|
|
|
if (target.rol === 'admin') {
|
|
const admins = usuarios.filter(u => u.rol === 'admin');
|
|
if (admins.length === 1) {
|
|
return res.status(400).json({ error: 'No puedes eliminar al único administrador' });
|
|
}
|
|
}
|
|
|
|
const filtrados = usuarios.filter(u => u.usuario !== targetUsuario);
|
|
await writeJson('contraseñas.json', { usuarios: filtrados });
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error('Delete user error:', err);
|
|
res.status(500).json({ error: 'Error al eliminar usuario' });
|
|
}
|
|
});
|
|
|
|
// ════════════════════════════════
|
|
// SUBIDA MASIVA DE PRODUCTOS (admin o permiso 'productos')
|
|
// ════════════════════════════════
|
|
|
|
// Multer config: usa memoria (luego sharp lo procesa y guarda en disco).
|
|
const MAX_FILES = 50;
|
|
const MAX_SIZE = 20 * 1024 * 1024; // 20MB por archivo
|
|
const MAX_WIDTH = 1200; // ancho (o alto) máx del webp resultante
|
|
const WEBP_QUALITY = 80;
|
|
|
|
const upload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: { fileSize: MAX_SIZE, files: MAX_FILES }
|
|
});
|
|
|
|
// Convención de nombres de archivo:
|
|
// La REFERENCIA es TODO el nombre antes de la extensión, sin importar
|
|
// qué tan complejo sea (puede tener guiones, guiones bajos, números, etc.).
|
|
// Cada archivo = 1 producto con 1 foto.
|
|
//
|
|
// "bill-001-1.jpg" → ref=bill-001-1
|
|
// "bill-001-2.jpg" → ref=bill-001-2 (producto DISTINTO al anterior)
|
|
// "GG-500505.jpg" → ref=GG-500505
|
|
// "mi-prod_loco.jpg" → ref=mi-prod_loco
|
|
//
|
|
// La referencia es el IDENTIFICATIVO del producto (como un SKU).
|
|
// Más adelante, cuando se suba el Excel/CSV, se matcheará por referencia
|
|
// para asignar la descripción (nombre visible) y el precio.
|
|
const REF_VALID_RE = /^[a-zA-Z0-9]+(?:[-_a-zA-Z0-9]*[a-zA-Z0-9])?$/;
|
|
|
|
function parseFilename(name) {
|
|
// Quitar extensión (todo lo que está después del último punto)
|
|
const base = name.replace(/\.[^.]+$/, '').trim();
|
|
if (!base) return null;
|
|
|
|
// Validar que la referencia tenga un formato aceptable
|
|
if (!REF_VALID_RE.test(base)) return null;
|
|
|
|
return { ref: base, num: 1 };
|
|
}
|
|
|
|
app.post('/api/productos/upload', requireProductosManagement, upload.array('fotos', MAX_FILES), async (req, res) => {
|
|
const categoria = (req.body.categoria || '').trim();
|
|
const files = req.files || [];
|
|
|
|
if (!categoria) {
|
|
return res.status(400).json({ error: 'Selecciona una categoría' });
|
|
}
|
|
if (files.length === 0) {
|
|
return res.status(400).json({ error: 'No se enviaron fotos' });
|
|
}
|
|
|
|
try {
|
|
// Verificar que la categoría existe y NO es sin-categoria
|
|
const categorias = await getCategorias();
|
|
const cat = categorias.find(c => c.id === categoria);
|
|
if (!cat) {
|
|
return res.status(400).json({ error: 'La categoría no existe' });
|
|
}
|
|
if (categoria === SIN_CATEGORIA_ID) {
|
|
return res.status(400).json({ error: 'No se puede subir productos a la categoría del sistema' });
|
|
}
|
|
|
|
// Crear carpeta destino si no existe
|
|
const dstDir = path.join(IMG_DIR, categoria);
|
|
await fs.mkdir(dstDir, { recursive: true });
|
|
|
|
// Cargar productos existentes
|
|
const productos = await getProductos();
|
|
const existentes = new Set(productos.map(p => p.ref || p.id));
|
|
|
|
const creados = [];
|
|
const saltados = [];
|
|
const ignorados = [];
|
|
const errores = [];
|
|
|
|
// Cada archivo = 1 producto con 1 foto. Procesamos secuencialmente.
|
|
for (const f of files) {
|
|
const parsed = parseFilename(f.originalname);
|
|
if (!parsed) {
|
|
ignorados.push({ archivo: f.originalname, motivo: 'Nombre de archivo inválido' });
|
|
continue;
|
|
}
|
|
|
|
const { ref } = parsed;
|
|
|
|
if (existentes.has(ref)) {
|
|
saltados.push({ ref, motivo: 'Ya existe un producto con esa referencia' });
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const outName = `${ref}.webp`;
|
|
const outPath = path.join(dstDir, outName);
|
|
|
|
await sharp(f.buffer)
|
|
.rotate() // respeta EXIF orientation
|
|
.resize({
|
|
width: MAX_WIDTH,
|
|
height: MAX_WIDTH,
|
|
fit: 'inside',
|
|
withoutEnlargement: true
|
|
})
|
|
.webp({ quality: WEBP_QUALITY })
|
|
.toFile(outPath);
|
|
|
|
productos.push({
|
|
id: ref,
|
|
ref: ref,
|
|
cat: categoria,
|
|
img: `${categoria}/${outName}`
|
|
});
|
|
creados.push({ ref });
|
|
} catch (err) {
|
|
console.error(`Error procesando ${ref}:`, err.message);
|
|
errores.push({ ref, motivo: 'Error al procesar la imagen' });
|
|
}
|
|
}
|
|
|
|
await saveProductos(productos);
|
|
|
|
res.json({
|
|
ok: true,
|
|
resumen: {
|
|
creados: creados.length,
|
|
creadosList: creados,
|
|
saltados: saltados.length,
|
|
saltadosList: saltados,
|
|
ignorados: ignorados.length,
|
|
ignoradosList: ignorados,
|
|
errores: errores.length,
|
|
erroresList: errores
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.error('Upload productos error:', err);
|
|
res.status(500).json({ error: 'Error al procesar la subida' });
|
|
}
|
|
});
|
|
|
|
// Marcar como AGOTADO (admin o permiso 'productos')
|
|
// Mueve la imagen a Imagenes/agotados/<ref>.webp y actualiza el JSON.
|
|
// El producto sigue en el JSON (con su categoría original), pero su `img`
|
|
// pasa a apuntar a "agotados/<ref>.webp", que es la señal de "agotado".
|
|
app.post('/api/productos/:ref/agotar', requireProductosManagement, async (req, res) => {
|
|
const ref = req.params.ref;
|
|
try {
|
|
const productos = await getProductos();
|
|
const idx = productos.findIndex(p => p.ref === ref);
|
|
if (idx === -1) return res.status(404).json({ error: 'Producto no encontrado' });
|
|
|
|
if (productos[idx].img && productos[idx].img.startsWith('agotados/')) {
|
|
return res.status(400).json({ error: 'El producto ya está agotado' });
|
|
}
|
|
|
|
const oldImg = productos[idx].img;
|
|
const newImg = await moveProductImage(oldImg, 'agotados', ref);
|
|
productos[idx].img = newImg;
|
|
await saveProductos(productos);
|
|
|
|
res.json({ ok: true, ref, img: newImg });
|
|
} catch (err) {
|
|
console.error('Agotar error:', err);
|
|
res.status(500).json({ error: 'Error al marcar como agotado' });
|
|
}
|
|
});
|
|
|
|
// REACTIVAR un producto agotado (admin o permiso 'productos')
|
|
// Mueve la imagen de vuelta a su carpeta de categoría original
|
|
// (o a "sin-categoria" si la categoría ya no existe) y actualiza el JSON.
|
|
app.post('/api/productos/:ref/reactivar', requireProductosManagement, async (req, res) => {
|
|
const ref = req.params.ref;
|
|
try {
|
|
const productos = await getProductos();
|
|
const idx = productos.findIndex(p => p.ref === ref);
|
|
if (idx === -1) return res.status(404).json({ error: 'Producto no encontrado' });
|
|
|
|
if (!productos[idx].img || !productos[idx].img.startsWith('agotados/')) {
|
|
return res.status(400).json({ error: 'El producto no está agotado' });
|
|
}
|
|
|
|
// Categoría original (la guardamos en el JSON al subir)
|
|
const targetCat = productos[idx].cat;
|
|
if (!targetCat) {
|
|
return res.status(400).json({ error: 'El producto no tiene categoría original' });
|
|
}
|
|
|
|
// Si la categoría original ya no existe, fallback a "sin-categoria"
|
|
const categorias = await getCategorias();
|
|
let destCat = targetCat;
|
|
if (!categorias.some(c => c.id === targetCat)) {
|
|
await ensureSinCategoria();
|
|
destCat = SIN_CATEGORIA_ID;
|
|
productos[idx].cat = SIN_CATEGORIA_ID;
|
|
}
|
|
|
|
const newImg = await moveProductImage(productos[idx].img, destCat, ref);
|
|
productos[idx].img = newImg;
|
|
await saveProductos(productos);
|
|
|
|
res.json({ ok: true, ref, img: newImg, cat: destCat });
|
|
} catch (err) {
|
|
console.error('Reactivar error:', err);
|
|
res.status(500).json({ error: 'Error al reactivar el producto' });
|
|
}
|
|
});
|
|
|
|
// ELIMINAR DEFINITIVAMENTE un producto (admin o permiso 'productos')
|
|
// Borra la imagen del disco y la entrada del JSON.
|
|
// Funciona tanto para productos disponibles como para agotados.
|
|
app.delete('/api/productos/:ref', requireProductosManagement, async (req, res) => {
|
|
const ref = req.params.ref;
|
|
try {
|
|
const productos = await getProductos();
|
|
const idx = productos.findIndex(p => p.ref === ref);
|
|
if (idx === -1) return res.status(404).json({ error: 'Producto no encontrado' });
|
|
|
|
const img = productos[idx].img;
|
|
await deleteProductImage(img);
|
|
|
|
productos.splice(idx, 1);
|
|
await saveProductos(productos);
|
|
|
|
res.json({ ok: true, ref });
|
|
} catch (err) {
|
|
console.error('Delete producto error:', err);
|
|
res.status(500).json({ error: 'Error al eliminar el producto' });
|
|
}
|
|
});
|
|
|
|
// ════════════════════════════════
|
|
// 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 });
|
|
});
|
|
|
|
// ════════════════════════════════
|
|
// START
|
|
// ════════════════════════════════
|
|
app.listen(PORT, () => {
|
|
console.log(`∞ Infinity server corriendo en http://localhost:${PORT}`);
|
|
scheduleSessionCleanup();
|
|
});
|