import { MongoClient } from 'mongodb'; import yaml from 'yaml'; import { readFileSync, existsSync } from 'fs'; import bcrypt from 'bcrypt'; import { RateLimiterMemory } from 'rate-limiter-flexible'; import { PATHS } from '../config/constants.js'; import { RATE_LIMIT } from '../config/constants.js'; import { readJSON } from '../utils/fileUtils.js'; let mongoClient = null; let db = null; let rateLimiter = null; let config = null; // Duración de sesión en milisegundos (24 horas) const SESSION_DURATION = 24 * 60 * 60 * 1000; // Inicializar MongoDB si está configurado export async function initMongoDB() { try { config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8')); const cacheConfig = config?.cache; if (cacheConfig?.type === 'mongodb') { const mongodbConfig = cacheConfig.mongodb; // En Docker, usar el nombre del servicio si no se especifica host const mongodbHost = process.env.MONGODB_HOST || mongodbConfig.host || 'localhost'; const mongodbPort = process.env.MONGODB_PORT || mongodbConfig.port || 27017; const database = process.env.MONGODB_DATABASE || mongodbConfig.database || 'wallabicher'; const username = process.env.MONGODB_USERNAME || mongodbConfig.username; const password = process.env.MONGODB_PASSWORD || mongodbConfig.password; const authSource = mongodbConfig.auth_source || 'admin'; // Construir URL de conexión let connectionString = 'mongodb://'; if (username && password) { connectionString += `${encodeURIComponent(username)}:${encodeURIComponent(password)}@`; } connectionString += `${mongodbHost}:${mongodbPort}`; if (username && password) { connectionString += `/?authSource=${authSource}`; } mongoClient = new MongoClient(connectionString); await mongoClient.connect(); db = mongoClient.db(database); console.log(`✅ Conectado a MongoDB (${database})`); // Crear índices await createIndexes(); // Inicializar rate limiter con memoria (MongoDB no tiene rate limiter nativo, usar memoria) try { rateLimiter = new RateLimiterMemory({ points: RATE_LIMIT.POINTS, duration: RATE_LIMIT.DURATION, blockDuration: RATE_LIMIT.BLOCK_DURATION, }); console.log('✅ Rate limiter inicializado con memoria'); } catch (error) { console.error('Error inicializando rate limiter:', error.message); } // Inicializar usuario admin por defecto si no existe await initDefaultAdmin(); // Migrar workers.json a MongoDB para admin si no existe await migrateWorkersFromFile(); } else { console.log('ℹ️ MongoDB no configurado, usando modo memoria'); console.log('⚠️ Rate limiting y autenticación requieren MongoDB'); } } catch (error) { console.error('Error inicializando MongoDB:', error.message); } } // Crear índices necesarios async function createIndexes() { if (!db) return; try { // Índices para usuarios await db.collection('users').createIndex({ username: 1 }, { unique: true }); // Índices para sesiones (con TTL) await db.collection('sessions').createIndex({ token: 1 }, { unique: true }); await db.collection('sessions').createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }); await db.collection('sessions').createIndex({ username: 1, fingerprint: 1 }); await db.collection('sessions').createIndex({ fingerprint: 1 }); // Índices para workers await db.collection('workers').createIndex({ username: 1 }); // Índices para artículos notificados await db.collection('articles').createIndex({ platform: 1, id: 1 }, { unique: true }); await db.collection('articles').createIndex({ 'user_info.username': 1 }); await db.collection('articles').createIndex({ 'user_info.worker_name': 1 }); await db.collection('articles').createIndex({ 'user_info.is_favorite': 1 }); // Índices para ordenamiento (NO usar user_info.notified_at porque es un array) await db.collection('articles').createIndex({ createdAt: -1 }); await db.collection('articles').createIndex({ modified_at: -1 }); await db.collection('articles').createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }); // Índices de compatibilidad con estructura antigua await db.collection('articles').createIndex({ username: 1 }); await db.collection('articles').createIndex({ worker_name: 1 }); // Índices para búsqueda eficiente por palabras (regex case-insensitive) // Estos índices mejoran el rendimiento de búsquedas regex en estos campos try { await db.collection('articles').createIndex({ title: 1 }); await db.collection('articles').createIndex({ description: 1 }); await db.collection('articles').createIndex({ location: 1 }); await db.collection('articles').createIndex({ price: 1 }); console.log('✅ Índices para búsqueda por palabras creados'); } catch (error) { // Si los índices ya existen, ignorar el error if (error.code !== 85 && error.code !== 86) { console.error('Error creando índices de búsqueda:', error.message); } } // Índice de texto para búsqueda eficiente por palabras (opcional, para búsqueda de texto completo) // Nota: MongoDB solo permite un índice de texto por colección try { await db.collection('articles').createIndex( { title: 'text', description: 'text', location: 'text', platform: 'text' }, { name: 'articles_text_search', weights: { title: 10, description: 5, location: 3, platform: 2 }, default_language: 'spanish' } ); console.log('✅ Índice de texto para búsqueda creado'); } catch (error) { // Si el índice ya existe, ignorar el error if (error.code !== 85 && error.code !== 86) { console.error('Error creando índice de texto:', error.message); } } console.log('✅ Índices de MongoDB creados'); } catch (error) { console.error('Error creando índices de MongoDB:', error.message); } } // Inicializar usuario admin por defecto async function initDefaultAdmin() { if (!db) return; try { const usersCollection = db.collection('users'); const adminExists = await usersCollection.findOne({ username: 'admin' }); if (!adminExists) { // Crear usuario admin por defecto con contraseña "admin" // En producción, esto debería cambiarse const defaultPassword = 'admin'; const hashedPassword = await bcrypt.hash(defaultPassword, 10); await usersCollection.insertOne({ username: 'admin', passwordHash: hashedPassword, role: 'admin', createdAt: new Date(), }); console.log('✅ Usuario admin creado por defecto (usuario: admin, contraseña: admin)'); console.log('⚠️ IMPORTANTE: Cambia la contraseña por defecto en producción'); } else { // Asegurar que el usuario admin tiene el rol correcto (para usuarios existentes) await usersCollection.updateOne( { username: 'admin' }, { $set: { role: 'admin' } } ); } } catch (error) { console.error('Error inicializando usuario admin:', error.message); } } // Migrar workers.json a MongoDB para el usuario admin si no existe async function migrateWorkersFromFile() { if (!db) return; try { const workersCollection = db.collection('workers'); const adminWorkers = await workersCollection.findOne({ username: 'admin' }); // Si ya existen workers para admin en MongoDB, no migrar if (adminWorkers) { console.log('ℹ️ Workers de admin ya existen en MongoDB, omitiendo migración'); return; } // Intentar leer workers.json if (!existsSync(PATHS.WORKERS)) { console.log('ℹ️ workers.json no existe, creando estructura vacía para admin'); // Crear estructura vacía por defecto const defaultWorkers = { username: 'admin', general: { title_exclude: [], description_exclude: [] }, items: [], disabled: [], updatedAt: new Date(), createdAt: new Date() }; await workersCollection.insertOne(defaultWorkers); return; } // Leer workers.json y migrar a MongoDB const workersData = readJSON(PATHS.WORKERS, { general: { title_exclude: [], description_exclude: [] }, items: [], disabled: [] }); // Guardar en MongoDB para admin await workersCollection.insertOne({ username: 'admin', ...workersData, updatedAt: new Date(), createdAt: new Date() }); console.log(`✅ Workers migrados desde workers.json al usuario admin (${workersData.items?.length || 0} items)`); } catch (error) { console.error('Error migrando workers.json a MongoDB:', error.message); // No lanzar error, solo registrar } } // Getters export function getMongoDBClient() { return mongoClient; } export function getDB() { return db; } export function getRateLimiter() { return rateLimiter; } // Obtener información del rate limiter y bloqueos (para memoria) export async function getRateLimiterInfo() { if (!rateLimiter) { return { enabled: false, type: 'none', blocks: [], stats: { totalBlocks: 0, activeBlocks: 0, }, }; } try { // RateLimiterMemory no expone directamente las claves bloqueadas // Necesitamos usar una aproximación diferente // Por ahora, retornamos información básica return { enabled: true, type: 'memory', blocks: [], stats: { totalBlocks: 0, activeBlocks: 0, }, config: { points: RATE_LIMIT.POINTS, duration: RATE_LIMIT.DURATION, blockDuration: RATE_LIMIT.BLOCK_DURATION, }, note: 'Rate limiter en memoria no expone información detallada de bloqueos', }; } catch (error) { console.error('Error obteniendo información del rate limiter:', error.message); return { enabled: true, type: 'memory', blocks: [], stats: { totalBlocks: 0, activeBlocks: 0, }, error: error.message, }; } } export function getConfig() { return config; } export function reloadConfig() { try { config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8')); return config; } catch (error) { console.error('Error recargando configuración:', error.message); return config; } } // Funciones de utilidad para artículos export async function getNotifiedArticles(filter = {}) { if (!db) { return []; } try { const articlesCollection = db.collection('articles'); // Construir query de filtro const query = {}; if (filter.platform) query.platform = filter.platform; // Si se especifica username, buscar en user_info if (filter.username) { query['user_info.username'] = filter.username; } // Si se especifica worker_name, buscar en user_info if (filter.worker_name) { query['user_info.worker_name'] = filter.worker_name; } // Ordenar SOLO por campos a nivel de artículo, NO por user_info.notified_at // porque user_info es un array y puede causar orden incorrecto const articles = await articlesCollection .find(query) .sort({ createdAt: -1, modified_at: -1 }) .toArray(); // Obtener el username del usuario actual para incluir su is_favorite const currentUsername = filter.currentUsername || filter.username; // Filtrar y transformar artículos según el usuario solicitado return articles.map(article => { // Si hay filtro de username, solo devolver el user_info correspondiente let relevantUserInfo = null; if (filter.username) { relevantUserInfo = (article.user_info || []).find( ui => ui.username === filter.username ); // Si no hay user_info para este usuario, no incluir el artículo if (!relevantUserInfo) return null; } else if (filter.worker_name) { // Si solo hay filtro de worker, buscar el primer user_info con ese worker relevantUserInfo = (article.user_info || []).find( ui => ui.worker_name === filter.worker_name ); if (!relevantUserInfo) return null; } // Construir el artículo con la información relevante const result = { ...article, _id: article._id.toString(), expiresAt: article.expiresAt?.getTime() || null, }; // Si hay un user_info específico, usar sus datos const fallbackTime = article.createdAt?.getTime?.() || (typeof article.createdAt === 'number' ? article.createdAt : null) || article.modified_at?.getTime?.() || null; if (relevantUserInfo) { result.username = relevantUserInfo.username; result.worker_name = relevantUserInfo.worker_name; result.is_favorite = relevantUserInfo.is_favorite || false; // NO usar Date.now() como fallback, para no mover artículos antiguos result.notifiedAt = relevantUserInfo.notified_at?.getTime?.() || (typeof relevantUserInfo.notified_at === 'number' ? relevantUserInfo.notified_at : null) || fallbackTime; } else { // Sin filtro específico, mostrar el primer user_info o datos generales const firstUserInfo = (article.user_info || [])[0]; if (firstUserInfo) { result.username = firstUserInfo.username; result.worker_name = firstUserInfo.worker_name; // Si hay un usuario actual, buscar su is_favorite específico, sino usar el del primer user_info if (currentUsername) { const currentUserInfo = (article.user_info || []).find( ui => ui.username === currentUsername ); result.is_favorite = currentUserInfo ? (currentUserInfo.is_favorite || false) : false; } else { result.is_favorite = firstUserInfo.is_favorite || false; } result.notifiedAt = firstUserInfo.notified_at?.getTime?.() || (typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) || fallbackTime; } else { // Compatibilidad con estructura antigua result.username = article.username; result.worker_name = article.worker_name; result.is_favorite = article.is_favorite || false; result.notifiedAt = article.notifiedAt?.getTime?.() || (typeof article.notifiedAt === 'number' ? article.notifiedAt : null) || fallbackTime; } } return result; }).filter(article => article !== null); } catch (error) { console.error('Error obteniendo artículos de MongoDB:', error.message); return []; } } export async function getArticleFacets(usernameFilter = null) { if (!db) { return { platforms: [], usernames: [], workers: [] }; } try { const articlesCollection = db.collection('articles'); // Construir query base const query = {}; if (usernameFilter) { // Si hay filtro de username, solo buscar artículos de ese usuario query['user_info.username'] = usernameFilter; } // Obtener todos los artículos que coincidan con el filtro const articles = await articlesCollection.find(query).toArray(); // Extraer valores únicos const platformsSet = new Set(); const usernamesSet = new Set(); const workersSet = new Set(); for (const article of articles) { // Plataforma (campo directo del artículo) if (article.platform) { platformsSet.add(article.platform); } // Username y worker_name (pueden estar en user_info o en campos directos para compatibilidad) const userInfoList = article.user_info || []; if (userInfoList.length > 0) { // Estructura nueva: usar user_info for (const userInfo of userInfoList) { if (userInfo.username) { usernamesSet.add(userInfo.username); } if (userInfo.worker_name) { workersSet.add(userInfo.worker_name); } } } else { // Estructura antigua: usar campos directos if (article.username) { usernamesSet.add(article.username); } if (article.worker_name) { workersSet.add(article.worker_name); } } } return { platforms: Array.from(platformsSet).sort(), usernames: Array.from(usernamesSet).sort(), workers: Array.from(workersSet).sort() }; } catch (error) { console.error('Error obteniendo facets de artículos:', error.message); return { platforms: [], usernames: [], workers: [] }; } } // Buscar artículos por texto de forma eficiente usando índices de MongoDB // La búsqueda es por palabras con soporte para modo AND u OR // AND: todas las palabras deben estar presentes // OR: al menos una palabra debe estar presente export async function searchArticles(searchQuery, filter = {}, searchMode = 'AND') { if (!db) { return []; } try { const articlesCollection = db.collection('articles'); // Limpiar y dividir la consulta en palabras const queryWords = searchQuery .trim() .split(/\s+/) .filter(word => word.length > 0) .map(word => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); // Escapar caracteres especiales de regex if (queryWords.length === 0) { return []; } // Construir query base con filtros const query = {}; // Aplicar filtros de usuario y worker if (filter.username) { query['user_info.username'] = filter.username; } if (filter.worker_name) { query['user_info.worker_name'] = filter.worker_name; } if (filter.platform) { query.platform = filter.platform; } // Campos de texto donde buscar (ordenados por importancia) const textFields = ['title', 'description', 'location', 'platform', 'id']; if (searchMode.toUpperCase() === 'OR') { // Modo OR: al menos una palabra debe estar presente const orConditions = []; // Para cada palabra, crear una condición que busque en todos los campos for (const word of queryWords) { const wordRegex = new RegExp(word, 'i'); // Case-insensitive // Condiciones para esta palabra en campos de texto principales const fieldConditions = textFields.map(field => ({ [field]: { $regex: wordRegex } })); // Buscar en user_info si no hay filtro específico if (!filter.username && !filter.worker_name) { fieldConditions.push( { 'user_info.username': { $regex: wordRegex } }, { 'user_info.worker_name': { $regex: wordRegex } }, { username: { $regex: wordRegex } }, // Compatibilidad con estructura antigua { worker_name: { $regex: wordRegex } } // Compatibilidad con estructura antigua ); } // Buscar precio como número (si la palabra es un número) const priceNum = parseFloat(word.replace(/[^\d.,]/g, '').replace(',', '.')); if (!isNaN(priceNum) && priceNum > 0) { // Buscar precio exacto o cercano (±10%) const priceLower = priceNum * 0.9; const priceUpper = priceNum * 1.1; fieldConditions.push({ price: { $gte: priceLower, $lte: priceUpper } }); } // Esta palabra debe estar en al menos uno de estos campos orConditions.push({ $or: fieldConditions }); } // Al menos una palabra debe estar presente if (orConditions.length > 0) { query.$or = orConditions; } } else { // Modo AND (por defecto): todas las palabras deben estar presentes const wordConditions = []; // Para cada palabra, crear una condición que busque en todos los campos de texto for (const word of queryWords) { const wordRegex = new RegExp(word, 'i'); // Case-insensitive // Condiciones para esta palabra en campos de texto principales const fieldConditions = textFields.map(field => ({ [field]: { $regex: wordRegex } })); // Buscar en user_info si no hay filtro específico if (!filter.username && !filter.worker_name) { fieldConditions.push( { 'user_info.username': { $regex: wordRegex } }, { 'user_info.worker_name': { $regex: wordRegex } }, { username: { $regex: wordRegex } }, // Compatibilidad con estructura antigua { worker_name: { $regex: wordRegex } } // Compatibilidad con estructura antigua ); } // Buscar precio como número (si la palabra es un número) const priceNum = parseFloat(word.replace(/[^\d.,]/g, '').replace(',', '.')); if (!isNaN(priceNum) && priceNum > 0) { // Buscar precio exacto o cercano (±10%) const priceLower = priceNum * 0.9; const priceUpper = priceNum * 1.1; fieldConditions.push({ price: { $gte: priceLower, $lte: priceUpper } }); } // Esta palabra debe estar en al menos uno de estos campos wordConditions.push({ $or: fieldConditions }); } // Todas las palabras deben estar presentes if (wordConditions.length > 0) { query.$and = wordConditions; } } // Ejecutar búsqueda con ordenamiento const articles = await articlesCollection .find(query) .sort({ createdAt: -1, modified_at: -1 }) .toArray(); // Obtener el username del usuario actual para incluir su is_favorite const currentUsername = filter.currentUsername || filter.username; // Transformar artículos según el usuario solicitado (similar a getNotifiedArticles) return articles.map(article => { let relevantUserInfo = null; if (filter.username) { relevantUserInfo = (article.user_info || []).find( ui => ui.username === filter.username ); if (!relevantUserInfo) return null; } else if (filter.worker_name) { relevantUserInfo = (article.user_info || []).find( ui => ui.worker_name === filter.worker_name ); if (!relevantUserInfo) return null; } const result = { ...article, _id: article._id.toString(), expiresAt: article.expiresAt?.getTime() || null, }; const fallbackTime = article.createdAt?.getTime?.() || (typeof article.createdAt === 'number' ? article.createdAt : null) || article.modified_at?.getTime?.() || null; if (relevantUserInfo) { result.username = relevantUserInfo.username; result.worker_name = relevantUserInfo.worker_name; result.is_favorite = relevantUserInfo.is_favorite || false; result.notifiedAt = relevantUserInfo.notified_at?.getTime?.() || (typeof relevantUserInfo.notified_at === 'number' ? relevantUserInfo.notified_at : null) || fallbackTime; } else { const firstUserInfo = (article.user_info || [])[0]; if (firstUserInfo) { result.username = firstUserInfo.username; result.worker_name = firstUserInfo.worker_name; // Si hay un usuario actual, buscar su is_favorite específico, sino usar el del primer user_info if (currentUsername) { const currentUserInfo = (article.user_info || []).find( ui => ui.username === currentUsername ); result.is_favorite = currentUserInfo ? (currentUserInfo.is_favorite || false) : false; } else { result.is_favorite = firstUserInfo.is_favorite || false; } result.notifiedAt = firstUserInfo.notified_at?.getTime?.() || (typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) || fallbackTime; } else { // Compatibilidad con estructura antigua result.username = article.username; result.worker_name = article.worker_name; result.is_favorite = article.is_favorite || false; result.notifiedAt = article.notifiedAt?.getTime?.() || (typeof article.notifiedAt === 'number' ? article.notifiedAt : null) || fallbackTime; } } return result; }).filter(article => article !== null); } catch (error) { console.error('Error buscando artículos en MongoDB:', error.message); return []; } } export async function getFavorites(username = null) { if (!db) { return []; } try { const articlesCollection = db.collection('articles'); // Si se especifica username, buscar solo favoritos de ese usuario let query = {}; if (username) { query['user_info.username'] = username; query['user_info.is_favorite'] = true; } else { // Sin username, buscar cualquier artículo con algún favorito query['user_info.is_favorite'] = true; } const articles = await articlesCollection .find(query) .sort({ createdAt: -1, modified_at: -1 }) .toArray(); // Filtrar y transformar para devolver solo los favoritos relevantes const favorites = []; for (const article of articles) { const userInfoList = article.user_info || []; if (username) { // Solo devolver favoritos del usuario especificado const userInfo = userInfoList.find(ui => ui.username === username && ui.is_favorite === true ); if (userInfo) { favorites.push({ ...article, _id: article._id.toString(), username: userInfo.username, worker_name: userInfo.worker_name, is_favorite: true, // NOTA: no usar Date.now() como fallback, para no mover favoritos antiguos notifiedAt: userInfo.notified_at?.getTime?.() || (typeof userInfo.notified_at === 'number' ? userInfo.notified_at : null) || article.createdAt?.getTime?.() || (typeof article.createdAt === 'number' ? article.createdAt : null) || article.modified_at?.getTime?.() || null, expiresAt: article.expiresAt?.getTime() || null, }); } } else { // Sin filtro de usuario, devolver todos los favoritos (uno por user_info) for (const userInfo of userInfoList) { if (userInfo.is_favorite === true) { favorites.push({ ...article, _id: article._id.toString(), username: userInfo.username, worker_name: userInfo.worker_name, is_favorite: true, // NOTA: no usar Date.now() como fallback, para no mover favoritos antiguos notifiedAt: userInfo.notified_at?.getTime?.() || (typeof userInfo.notified_at === 'number' ? userInfo.notified_at : null) || article.createdAt?.getTime?.() || (typeof article.createdAt === 'number' ? article.createdAt : null) || article.modified_at?.getTime?.() || null, expiresAt: article.expiresAt?.getTime() || null, }); } } } } return favorites; } catch (error) { console.error('Error obteniendo favoritos de MongoDB:', error.message); return []; } } // Inicializar claves conocidas para evitar notificar artículos existentes export async function initNotifiedArticleKeys() { if (!db) { return new Set(); } try { const articlesCollection = db.collection('articles'); const articles = await articlesCollection.find({}, { projection: { platform: 1, id: 1 } }).toArray(); const keysSet = new Set(articles.map(a => `notified:${a.platform}:${a.id}`)); console.log(`📋 ${keysSet.size} artículos ya notificados detectados`); return keysSet; } catch (error) { console.error('Error inicializando claves de artículos:', error.message); return new Set(); } } // Funciones para manejar workers por usuario export async function getWorkers(username) { if (!db) { throw new Error('MongoDB no está disponible'); } try { const workersCollection = db.collection('workers'); const workersData = await workersCollection.findOne({ username }); if (!workersData) { // Retornar estructura vacía por defecto return { general: { title_exclude: [], description_exclude: [] }, items: [], disabled: [] }; } // Remover campos de MongoDB y devolver solo los datos relevantes const { _id, username: _, updatedAt, createdAt, ...data } = workersData; return data; } catch (error) { console.error(`Error obteniendo workers para ${username}:`, error.message); throw error; } } export async function setWorkers(username, workers) { if (!db) { throw new Error('MongoDB no está disponible'); } try { const workersCollection = db.collection('workers'); // Usar upsert para insertar o actualizar await workersCollection.updateOne( { username }, { $set: { ...workers, username, updatedAt: new Date(), }, $setOnInsert: { createdAt: new Date(), } }, { upsert: true } ); return true; } catch (error) { console.error(`Error guardando workers para ${username}:`, error.message); throw error; } } // Funciones para usuarios export async function getUser(username) { if (!db) { return null; } try { const usersCollection = db.collection('users'); const user = await usersCollection.findOne({ username }); // Si el usuario no tiene rol, asignar 'user' por defecto (para usuarios antiguos) if (user && !user.role) { await usersCollection.updateOne( { username }, { $set: { role: username === 'admin' ? 'admin' : 'user' } } ); user.role = username === 'admin' ? 'admin' : 'user'; } return user; } catch (error) { console.error(`Error obteniendo usuario ${username}:`, error.message); return null; } } export async function createUser(userData) { if (!db) { throw new Error('MongoDB no está disponible'); } try { const usersCollection = db.collection('users'); const result = await usersCollection.insertOne({ ...userData, role: userData.role || 'user', // Por defecto 'user', a menos que se especifique createdAt: new Date(), }); return result.insertedId; } catch (error) { console.error('Error creando usuario:', error.message); throw error; } } export async function deleteUser(username) { if (!db) { return false; } try { const usersCollection = db.collection('users'); const result = await usersCollection.deleteOne({ username }); return result.deletedCount > 0; } catch (error) { console.error(`Error eliminando usuario ${username}:`, error.message); return false; } } export async function getAllUsers(currentUser = null) { if (!db) { return []; } try { const usersCollection = db.collection('users'); // Si hay un usuario actual, verificar si es admin if (currentUser) { const currentUserData = await getUser(currentUser); // Usar getUser para asegurar que tiene rol // Si es admin, puede ver todos los usuarios if (currentUserData && currentUserData.role === 'admin') { const users = await usersCollection.find({}, { projection: { passwordHash: 0 } }).toArray(); return users; } // Si no es admin, solo puede ver su propio usuario const user = await usersCollection.findOne( { username: currentUser }, { projection: { passwordHash: 0 } } ); return user ? [user] : []; } // Sin usuario actual, devolver todos (compatibilidad) const users = await usersCollection.find({}, { projection: { passwordHash: 0 } }).toArray(); return users; } catch (error) { console.error('Error obteniendo usuarios:', error.message); return []; } } export async function updateUserPassword(username, passwordHash) { if (!db) { throw new Error('MongoDB no está disponible'); } try { const usersCollection = db.collection('users'); await usersCollection.updateOne( { username }, { $set: { passwordHash, updatedAt: new Date() } } ); return true; } catch (error) { console.error(`Error actualizando contraseña de ${username}:`, error.message); throw error; } } // Funciones para configuración de Telegram export async function getTelegramConfig(username) { if (!db) { return null; } try { const usersCollection = db.collection('users'); const user = await usersCollection.findOne({ username }); if (user && user.telegram) { return { token: user.telegram.token || '', channel: user.telegram.channel || '', enable_polling: user.telegram.enable_polling || false }; } return null; } catch (error) { console.error(`Error obteniendo configuración de Telegram para ${username}:`, error.message); return null; } } export async function setTelegramConfig(username, telegramConfig) { if (!db) { throw new Error('MongoDB no está disponible'); } try { const usersCollection = db.collection('users'); // Verificar que el usuario existe const user = await usersCollection.findOne({ username }); if (!user) { throw new Error(`Usuario ${username} no existe`); } // Actualizar configuración de Telegram await usersCollection.updateOne( { username }, { $set: { telegram: { token: telegramConfig.token || '', channel: telegramConfig.channel || '', enable_polling: telegramConfig.enable_polling || false }, updatedAt: new Date() } } ); return true; } catch (error) { console.error(`Error guardando configuración de Telegram para ${username}:`, error.message); throw error; } } // Funciones para sesiones export async function createSession(username, fingerprint = null, deviceInfo = null) { if (!db) { throw new Error('MongoDB no está disponible'); } const crypto = await import('crypto'); const token = crypto.randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + SESSION_DURATION); try { const sessionsCollection = db.collection('sessions'); // Si hay fingerprint, eliminar sesiones anteriores del mismo dispositivo/usuario if (fingerprint) { await sessionsCollection.deleteMany({ username, fingerprint, expiresAt: { $gt: new Date() } // Solo eliminar sesiones activas }); } // Crear nueva sesión con fingerprint await sessionsCollection.insertOne({ token, username, fingerprint: fingerprint || null, deviceInfo: deviceInfo || null, createdAt: new Date(), expiresAt, }); return token; } catch (error) { console.error('Error creando sesión:', error.message); throw error; } } export async function getSession(token) { if (!db) { return null; } try { const sessionsCollection = db.collection('sessions'); return await sessionsCollection.findOne({ token }); } catch (error) { console.error('Error obteniendo sesión:', error.message); return null; } } export async function deleteSession(token) { if (!db) { return false; } try { const sessionsCollection = db.collection('sessions'); const result = await sessionsCollection.deleteOne({ token }); return result.deletedCount > 0; } catch (error) { console.error('Error eliminando sesión:', error.message); return false; } } export async function deleteUserSessions(username) { if (!db) { return 0; } try { const sessionsCollection = db.collection('sessions'); const result = await sessionsCollection.deleteMany({ username }); return result.deletedCount; } catch (error) { console.error(`Error eliminando sesiones de ${username}:`, error.message); return 0; } } export async function getAllSessions() { if (!db) { return []; } try { const sessionsCollection = db.collection('sessions'); const sessions = await sessionsCollection.find({}).toArray(); return sessions.map(session => ({ token: session.token, username: session.username, fingerprint: session.fingerprint || null, deviceInfo: session.deviceInfo || null, createdAt: session.createdAt, expiresAt: session.expiresAt, isExpired: session.expiresAt ? new Date(session.expiresAt) < new Date() : false, })); } catch (error) { console.error('Error obteniendo todas las sesiones:', error.message); return []; } } // Funciones para artículos export async function getArticle(platform, id, currentUsername = null) { if (!db) { return null; } try { const articlesCollection = db.collection('articles'); const article = await articlesCollection.findOne({ platform, id }); if (!article) { return null; } // Transformar el artículo similar a getNotifiedArticles para incluir is_favorite const result = { ...article, _id: article._id.toString(), expiresAt: article.expiresAt?.getTime() || null, }; const fallbackTime = article.createdAt?.getTime?.() || (typeof article.createdAt === 'number' ? article.createdAt : null) || article.modified_at?.getTime?.() || null; // Si hay un usuario actual, buscar su is_favorite específico if (currentUsername) { const currentUserInfo = (article.user_info || []).find( ui => ui.username === currentUsername ); if (currentUserInfo) { result.is_favorite = currentUserInfo.is_favorite || false; result.notifiedAt = currentUserInfo.notified_at?.getTime?.() || (typeof currentUserInfo.notified_at === 'number' ? currentUserInfo.notified_at : null) || fallbackTime; } else { // Si no hay user_info para este usuario, usar false result.is_favorite = false; } } else { // Sin usuario específico, usar el primer user_info o datos generales const firstUserInfo = (article.user_info || [])[0]; if (firstUserInfo) { result.username = firstUserInfo.username; result.worker_name = firstUserInfo.worker_name; result.is_favorite = firstUserInfo.is_favorite || false; result.notifiedAt = firstUserInfo.notified_at?.getTime?.() || (typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) || fallbackTime; } else { // Compatibilidad con estructura antigua result.username = article.username; result.worker_name = article.worker_name; result.is_favorite = article.is_favorite || false; result.notifiedAt = article.notifiedAt?.getTime?.() || (typeof article.notifiedAt === 'number' ? article.notifiedAt : null) || fallbackTime; } } return result; } catch (error) { console.error('Error obteniendo artículo:', error.message); return null; } } export async function updateArticleFavorite(platform, id, is_favorite, username) { if (!db) { throw new Error('MongoDB no está disponible'); } if (!username) { throw new Error('username es requerido para actualizar favoritos'); } try { const articlesCollection = db.collection('articles'); const article = await articlesCollection.findOne({ platform, id }); if (!article) { throw new Error('Artículo no encontrado'); } const userInfoList = article.user_info || []; const userInfoIndex = userInfoList.findIndex(ui => ui.username === username); if (userInfoIndex >= 0) { // Actualizar user_info existente userInfoList[userInfoIndex].is_favorite = is_favorite; } else { // Si no existe user_info para este usuario, crear uno userInfoList.push({ username, worker_name: null, notified: false, notified_at: null, is_favorite: is_favorite, }); } await articlesCollection.updateOne( { platform, id }, { $set: { user_info: userInfoList, updatedAt: new Date() } } ); return true; } catch (error) { console.error('Error actualizando favorito:', error.message); throw error; } } // Funciones para suscripciones export async function getUserSubscription(username) { if (!db) { return null; } try { const usersCollection = db.collection('users'); const user = await usersCollection.findOne({ username }); if (!user) { return null; } // Si no tiene suscripción, retornar plan gratuito por defecto if (!user.subscription) { return { planId: 'free', status: 'active', currentPeriodStart: user.createdAt || new Date(), currentPeriodEnd: null, // Plan gratuito no expira cancelAtPeriodEnd: false, }; } return user.subscription; } catch (error) { console.error(`Error obteniendo suscripción de ${username}:`, error.message); return null; } } export async function updateUserSubscription(username, subscriptionData) { if (!db) { throw new Error('MongoDB no está disponible'); } try { const usersCollection = db.collection('users'); // Verificar que el usuario existe const user = await usersCollection.findOne({ username }); if (!user) { throw new Error(`Usuario ${username} no existe`); } // Actualizar suscripción await usersCollection.updateOne( { username }, { $set: { subscription: { planId: subscriptionData.planId || 'free', status: subscriptionData.status || 'active', currentPeriodStart: subscriptionData.currentPeriodStart || new Date(), currentPeriodEnd: subscriptionData.currentPeriodEnd || null, cancelAtPeriodEnd: subscriptionData.cancelAtPeriodEnd || false, stripeCustomerId: subscriptionData.stripeCustomerId || null, stripeSubscriptionId: subscriptionData.stripeSubscriptionId || null, updatedAt: new Date(), }, updatedAt: new Date(), }, } ); return true; } catch (error) { console.error(`Error actualizando suscripción de ${username}:`, error.message); throw error; } } export async function getWorkerCount(username) { if (!db) { return 0; } try { const workersCollection = db.collection('workers'); const workersData = await workersCollection.findOne({ username }); if (!workersData || !workersData.items) { return 0; } // Contar solo workers activos (no deshabilitados) const activeWorkers = (workersData.items || []).filter( (item, index) => !(workersData.disabled || []).includes(index) ); return activeWorkers.length; } catch (error) { console.error(`Error contando workers de ${username}:`, error.message); return 0; } } // Cerrar conexión export async function closeMongoDB() { if (mongoClient) { await mongoClient.close(); mongoClient = null; db = null; console.log('✅ Conexión a MongoDB cerrada'); } }