mongodb
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
30
web/backend/middlewares/adminAuth.js
Normal file
30
web/backend/middlewares/adminAuth.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { getUser } from '../services/mongodb.js';
|
||||||
|
|
||||||
|
// Middleware para verificar que el usuario es administrador
|
||||||
|
export async function adminAuthMiddleware(req, res, next) {
|
||||||
|
// Debe estar autenticado primero (requiere basicAuthMiddleware antes)
|
||||||
|
if (!req.user || !req.user.username) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required', message: 'Se requiere autenticación' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await getUser(req.user.username);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Invalid user', message: 'Usuario no encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRole = user.role || 'user';
|
||||||
|
|
||||||
|
if (userRole !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'Forbidden', message: 'Se requieren permisos de administrador' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usuario es admin, continuar
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verificando permisos de admin:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error', message: 'Error verificando permisos' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { getRedisClient } from '../services/redis.js';
|
import { getDB, getSession, deleteSession as deleteSessionFromDB, deleteUserSessions as deleteUserSessionsFromDB, getUser } from '../services/mongodb.js';
|
||||||
|
|
||||||
// Duración de la sesión en segundos (24 horas)
|
// Duración de la sesión en milisegundos (24 horas)
|
||||||
const SESSION_DURATION = 24 * 60 * 60;
|
const SESSION_DURATION = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
// Generar token seguro
|
// Generar token seguro
|
||||||
function generateToken() {
|
function generateToken() {
|
||||||
@@ -11,10 +11,10 @@ function generateToken() {
|
|||||||
|
|
||||||
// Autenticación por token Middleware
|
// Autenticación por token Middleware
|
||||||
export async function authMiddleware(req, res, next) {
|
export async function authMiddleware(req, res, next) {
|
||||||
const redisClient = getRedisClient();
|
const db = getDB();
|
||||||
|
|
||||||
if (!redisClient) {
|
if (!db) {
|
||||||
return res.status(500).json({ error: 'Redis no está disponible. La autenticación requiere Redis.' });
|
return res.status(500).json({ error: 'MongoDB no está disponible. La autenticación requiere MongoDB.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
@@ -30,32 +30,41 @@ export async function authMiddleware(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verificar token en Redis
|
// Verificar token en MongoDB
|
||||||
const sessionKey = `session:${token}`;
|
const session = await getSession(token);
|
||||||
const sessionData = await redisClient.get(sessionKey);
|
|
||||||
|
|
||||||
if (!sessionData) {
|
if (!session) {
|
||||||
return res.status(401).json({ error: 'Invalid token', message: 'Token inválido o sesión expirada' });
|
return res.status(401).json({ error: 'Invalid token', message: 'Token inválido o sesión expirada' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parsear datos de sesión
|
// Verificar que la sesión no haya expirado
|
||||||
const session = JSON.parse(sessionData);
|
if (session.expiresAt && new Date(session.expiresAt) < new Date()) {
|
||||||
|
await deleteSessionFromDB(token);
|
||||||
|
return res.status(401).json({ error: 'Invalid token', message: 'Sesión expirada' });
|
||||||
|
}
|
||||||
|
|
||||||
// Verificar que el usuario aún existe
|
// Verificar que el usuario aún existe
|
||||||
const userKey = `user:${session.username}`;
|
const user = await getUser(session.username);
|
||||||
const userExists = await redisClient.exists(userKey);
|
|
||||||
|
|
||||||
if (!userExists) {
|
if (!user) {
|
||||||
// Eliminar sesión si el usuario ya no existe
|
// Eliminar sesión si el usuario ya no existe
|
||||||
await redisClient.del(sessionKey);
|
await deleteSessionFromDB(token);
|
||||||
return res.status(401).json({ error: 'Invalid token', message: 'Usuario no encontrado' });
|
return res.status(401).json({ error: 'Invalid token', message: 'Usuario no encontrado' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actualizar TTL de la sesión (refresh)
|
// Actualizar expiración de la sesión (refresh)
|
||||||
await redisClient.expire(sessionKey, SESSION_DURATION);
|
const sessionsCollection = db.collection('sessions');
|
||||||
|
const newExpiresAt = new Date(Date.now() + SESSION_DURATION);
|
||||||
|
await sessionsCollection.updateOne(
|
||||||
|
{ token },
|
||||||
|
{ $set: { expiresAt: newExpiresAt } }
|
||||||
|
);
|
||||||
|
|
||||||
// Autenticación exitosa
|
// Autenticación exitosa - incluir rol en req.user
|
||||||
req.user = { username: session.username };
|
req.user = {
|
||||||
|
username: session.username,
|
||||||
|
role: user.role || 'user' // Por defecto 'user' si no tiene rol
|
||||||
|
};
|
||||||
req.token = token;
|
req.token = token;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -69,35 +78,14 @@ export const basicAuthMiddleware = authMiddleware;
|
|||||||
|
|
||||||
// Función para crear sesión
|
// Función para crear sesión
|
||||||
export async function createSession(username) {
|
export async function createSession(username) {
|
||||||
const redisClient = getRedisClient();
|
const { createSession: createSessionInDB } = await import('../services/mongodb.js');
|
||||||
if (!redisClient) {
|
return await createSessionInDB(username);
|
||||||
throw new Error('Redis no está disponible');
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = generateToken();
|
|
||||||
const sessionKey = `session:${token}`;
|
|
||||||
const sessionData = {
|
|
||||||
username,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Almacenar sesión en Redis con TTL
|
|
||||||
await redisClient.setEx(sessionKey, SESSION_DURATION, JSON.stringify(sessionData));
|
|
||||||
|
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Función para invalidar sesión
|
// Función para invalidar sesión
|
||||||
export async function invalidateSession(token) {
|
export async function invalidateSession(token) {
|
||||||
const redisClient = getRedisClient();
|
|
||||||
if (!redisClient) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionKey = `session:${token}`;
|
return await deleteSessionFromDB(token);
|
||||||
await redisClient.del(sessionKey);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error invalidando sesión:', error);
|
console.error('Error invalidando sesión:', error);
|
||||||
return false;
|
return false;
|
||||||
@@ -106,31 +94,11 @@ export async function invalidateSession(token) {
|
|||||||
|
|
||||||
// Función para invalidar todas las sesiones de un usuario
|
// Función para invalidar todas las sesiones de un usuario
|
||||||
export async function invalidateUserSessions(username) {
|
export async function invalidateUserSessions(username) {
|
||||||
const redisClient = getRedisClient();
|
|
||||||
if (!redisClient) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Buscar todas las sesiones del usuario
|
return await deleteUserSessionsFromDB(username);
|
||||||
const keys = await redisClient.keys('session:*');
|
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
const sessionData = await redisClient.get(key);
|
|
||||||
if (sessionData) {
|
|
||||||
const session = JSON.parse(sessionData);
|
|
||||||
if (session.username === username) {
|
|
||||||
await redisClient.del(key);
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return count;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error invalidando sesiones del usuario:', error);
|
console.error('Error invalidando sesiones del usuario:', error);
|
||||||
return false;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getRateLimiter } from '../services/redis.js';
|
import { getRateLimiter } from '../services/mongodb.js';
|
||||||
|
|
||||||
// Rate Limiter Middleware
|
// Rate Limiter Middleware
|
||||||
export async function rateLimitMiddleware(req, res, next) {
|
export async function rateLimitMiddleware(req, res, next) {
|
||||||
|
|||||||
227
web/backend/package-lock.json
generated
227
web/backend/package-lock.json
generated
@@ -13,8 +13,8 @@
|
|||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"mongodb": "^6.3.0",
|
||||||
"rate-limiter-flexible": "^5.0.3",
|
"rate-limiter-flexible": "^5.0.3",
|
||||||
"redis": "^4.6.10",
|
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
"yaml": "^2.3.4"
|
"yaml": "^2.3.4"
|
||||||
@@ -88,63 +88,28 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@redis/bloom": {
|
"node_modules/@mongodb-js/saslprep": {
|
||||||
"version": "1.2.0",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.5.tgz",
|
||||||
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
|
"integrity": "sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==",
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@redis/client": {
|
|
||||||
"version": "1.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
|
||||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cluster-key-slot": "1.1.2",
|
"sparse-bitfield": "^3.0.3"
|
||||||
"generic-pool": "3.9.0",
|
|
||||||
"yallist": "4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@redis/graph": {
|
"node_modules/@types/webidl-conversions": {
|
||||||
"version": "1.1.1",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
|
||||||
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
|
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@redis/json": {
|
"node_modules/@types/whatwg-url": {
|
||||||
"version": "1.0.7",
|
"version": "11.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
|
||||||
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
|
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"dependencies": {
|
||||||
"@redis/client": "^1.0.0"
|
"@types/webidl-conversions": "*"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@redis/search": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@redis/time-series": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^1.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/abbrev": {
|
"node_modules/abbrev": {
|
||||||
@@ -319,6 +284,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bson": {
|
||||||
|
"version": "6.10.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz",
|
||||||
|
"integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/buffer-equal-constant-time": {
|
"node_modules/buffer-equal-constant-time": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
@@ -396,15 +370,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cluster-key-slot": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color-support": {
|
"node_modules/color-support": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||||
@@ -775,15 +740,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/generic-pool": {
|
|
||||||
"version": "3.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
|
|
||||||
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -1113,6 +1069,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/memory-pager": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/merge-descriptors": {
|
"node_modules/merge-descriptors": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||||
@@ -1237,6 +1199,96 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mongodb": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@mongodb-js/saslprep": "^1.3.0",
|
||||||
|
"bson": "^6.10.4",
|
||||||
|
"mongodb-connection-string-url": "^3.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@aws-sdk/credential-providers": "^3.188.0",
|
||||||
|
"@mongodb-js/zstd": "^1.1.0 || ^2.0.0",
|
||||||
|
"gcp-metadata": "^5.2.0",
|
||||||
|
"kerberos": "^2.0.1",
|
||||||
|
"mongodb-client-encryption": ">=6.0.0 <7",
|
||||||
|
"snappy": "^7.3.2",
|
||||||
|
"socks": "^2.7.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@aws-sdk/credential-providers": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@mongodb-js/zstd": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"gcp-metadata": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"kerberos": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"mongodb-client-encryption": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"snappy": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"socks": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mongodb-connection-string-url": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/whatwg-url": "^11.0.2",
|
||||||
|
"whatwg-url": "^14.1.0 || ^13.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mongodb-connection-string-url/node_modules/tr46": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mongodb-connection-string-url/node_modules/whatwg-url": {
|
||||||
|
"version": "14.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||||
|
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "^5.1.0",
|
||||||
|
"webidl-conversions": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
@@ -1406,6 +1458,15 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/punycode": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.1",
|
"version": "6.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||||
@@ -1477,23 +1538,6 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/redis": {
|
|
||||||
"version": "4.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
|
|
||||||
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"./packages/*"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"@redis/bloom": "1.2.0",
|
|
||||||
"@redis/client": "1.6.1",
|
|
||||||
"@redis/graph": "1.1.1",
|
|
||||||
"@redis/json": "1.0.7",
|
|
||||||
"@redis/search": "1.2.0",
|
|
||||||
"@redis/time-series": "1.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/rimraf": {
|
"node_modules/rimraf": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||||
@@ -1683,6 +1727,15 @@
|
|||||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/sparse-bitfield": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"memory-pager": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"rate-limiter-flexible": "^5.0.3",
|
"rate-limiter-flexible": "^5.0.3",
|
||||||
"redis": "^4.6.10",
|
"mongodb": "^6.3.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
"yaml": "^2.3.4"
|
"yaml": "^2.3.4"
|
||||||
|
|||||||
@@ -1,16 +1,50 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { getNotifiedArticles } from '../services/redis.js';
|
import { getNotifiedArticles } from '../services/mongodb.js';
|
||||||
|
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Obtener artículos notificados
|
router.delete('/', async (req, res) => {
|
||||||
router.get('/', async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const articles = await getNotifiedArticles();
|
const count = await clearAllArticles();
|
||||||
|
res.json({ success: true, message: `Todos los artículos eliminados: ${count} artículos borrados`, count });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obtener artículos notificados (requiere autenticación obligatoria)
|
||||||
|
router.get('/', basicAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Obtener usuario autenticado (requerido)
|
||||||
|
const user = req.user;
|
||||||
|
const isAdmin = user.role === 'admin';
|
||||||
|
|
||||||
|
// Construir filtro
|
||||||
|
const filter = {};
|
||||||
|
|
||||||
|
// Si no es admin, solo mostrar sus artículos
|
||||||
|
if (!isAdmin) {
|
||||||
|
filter.username = user.username;
|
||||||
|
} else if (req.query.username) {
|
||||||
|
// Admin puede filtrar por username
|
||||||
|
filter.username = req.query.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.worker_name) filter.worker_name = req.query.worker_name;
|
||||||
|
if (req.query.platform) filter.platform = req.query.platform;
|
||||||
|
|
||||||
|
const articles = await getNotifiedArticles(filter);
|
||||||
|
|
||||||
const limit = parseInt(req.query.limit) || 100;
|
const limit = parseInt(req.query.limit) || 100;
|
||||||
const offset = parseInt(req.query.offset) || 0;
|
const offset = parseInt(req.query.offset) || 0;
|
||||||
|
|
||||||
const sorted = articles.sort((a, b) => b.notifiedAt - a.notifiedAt);
|
// Los artículos ya vienen ordenados de MongoDB, pero asegurémonos
|
||||||
|
const sorted = articles.sort((a, b) => {
|
||||||
|
const aTime = typeof a.notifiedAt === 'number' ? a.notifiedAt : new Date(a.notifiedAt).getTime();
|
||||||
|
const bTime = typeof b.notifiedAt === 'number' ? b.notifiedAt : new Date(b.notifiedAt).getTime();
|
||||||
|
return bTime - aTime;
|
||||||
|
});
|
||||||
const paginated = sorted.slice(offset, offset + limit);
|
const paginated = sorted.slice(offset, offset + limit);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -24,16 +58,34 @@ router.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Buscar artículos en Redis
|
// Buscar artículos en MongoDB (requiere autenticación obligatoria)
|
||||||
router.get('/search', async (req, res) => {
|
router.get('/search', basicAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const query = req.query.q || '';
|
const query = req.query.q || '';
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
return res.json({ articles: [], total: 0 });
|
return res.json({ articles: [], total: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Obtener usuario autenticado (requerido)
|
||||||
|
const user = req.user;
|
||||||
|
const isAdmin = user.role === 'admin';
|
||||||
|
|
||||||
|
// Construir filtro adicional si se proporciona
|
||||||
|
const filter = {};
|
||||||
|
|
||||||
|
// Si no es admin, solo buscar sus artículos
|
||||||
|
if (!isAdmin) {
|
||||||
|
filter.username = user.username;
|
||||||
|
} else if (req.query.username) {
|
||||||
|
// Admin puede filtrar por username
|
||||||
|
filter.username = req.query.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.worker_name) filter.worker_name = req.query.worker_name;
|
||||||
|
if (req.query.platform) filter.platform = req.query.platform;
|
||||||
|
|
||||||
const searchTerm = query.toLowerCase().trim();
|
const searchTerm = query.toLowerCase().trim();
|
||||||
const allArticles = await getNotifiedArticles();
|
const allArticles = await getNotifiedArticles(filter);
|
||||||
|
|
||||||
// Filtrar artículos que coincidan con la búsqueda
|
// Filtrar artículos que coincidan con la búsqueda
|
||||||
const filtered = allArticles.filter(article => {
|
const filtered = allArticles.filter(article => {
|
||||||
@@ -61,11 +113,23 @@ router.get('/search', async (req, res) => {
|
|||||||
const id = String(article.id || '').toLowerCase();
|
const id = String(article.id || '').toLowerCase();
|
||||||
if (id.includes(searchTerm)) return true;
|
if (id.includes(searchTerm)) return true;
|
||||||
|
|
||||||
|
// Buscar en username
|
||||||
|
const username = (article.username || '').toLowerCase();
|
||||||
|
if (username.includes(searchTerm)) return true;
|
||||||
|
|
||||||
|
// Buscar en worker_name
|
||||||
|
const worker_name = (article.worker_name || '').toLowerCase();
|
||||||
|
if (worker_name.includes(searchTerm)) return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ordenar por fecha de notificación (más recientes primero)
|
// Ordenar por fecha de notificación (más recientes primero)
|
||||||
const sorted = filtered.sort((a, b) => b.notifiedAt - a.notifiedAt);
|
const sorted = filtered.sort((a, b) => {
|
||||||
|
const aTime = typeof a.notifiedAt === 'number' ? a.notifiedAt : new Date(a.notifiedAt).getTime();
|
||||||
|
const bTime = typeof b.notifiedAt === 'number' ? b.notifiedAt : new Date(b.notifiedAt).getTime();
|
||||||
|
return bTime - aTime;
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
articles: sorted,
|
articles: sorted,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||||
import { getConfig, reloadConfig } from '../services/redis.js';
|
import { getConfig, reloadConfig } from '../services/mongodb.js';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import yaml from 'yaml';
|
import yaml from 'yaml';
|
||||||
import { PATHS } from '../config/constants.js';
|
import { PATHS } from '../config/constants.js';
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { getFavorites, getRedisClient } from '../services/redis.js';
|
import { getFavorites, getDB, updateArticleFavorite, getArticle } from '../services/mongodb.js';
|
||||||
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||||
import { broadcast } from '../services/websocket.js';
|
import { broadcast } from '../services/websocket.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Obtener favoritos
|
// Obtener favoritos (requiere autenticación)
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', basicAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const favorites = await getFavorites();
|
// Obtener usuario autenticado (requerido)
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
// Todos los usuarios (incluidos admins) ven solo sus propios favoritos
|
||||||
|
const username = user.username;
|
||||||
|
const favorites = await getFavorites(username);
|
||||||
res.json(favorites);
|
res.json(favorites);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -18,41 +23,40 @@ router.get('/', async (req, res) => {
|
|||||||
// Añadir favorito (requiere autenticación)
|
// Añadir favorito (requiere autenticación)
|
||||||
router.post('/', basicAuthMiddleware, async (req, res) => {
|
router.post('/', basicAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const redisClient = getRedisClient();
|
const db = getDB();
|
||||||
if (!redisClient) {
|
if (!db) {
|
||||||
return res.status(500).json({ error: 'Redis no está disponible' });
|
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verificar que el usuario está autenticado (middleware ya lo valida, pero doble verificación)
|
||||||
|
if (!req.user || !req.user.username) {
|
||||||
|
return res.status(401).json({ error: 'Se requiere autenticación', message: 'Se requiere autenticación para marcar favoritos' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = req.user.username;
|
||||||
const { platform, id } = req.body;
|
const { platform, id } = req.body;
|
||||||
if (!platform || !id) {
|
if (!platform || !id) {
|
||||||
return res.status(400).json({ error: 'platform e id son requeridos' });
|
return res.status(400).json({ error: 'platform e id son requeridos' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = `notified:${platform}:${id}`;
|
// Convertir id a string para consistencia
|
||||||
const value = await redisClient.get(key);
|
const idStr = String(id);
|
||||||
|
|
||||||
if (!value) {
|
// Verificar si el artículo existe
|
||||||
return res.status(404).json({ error: 'Artículo no encontrado' });
|
const article = await getArticle(platform, idStr);
|
||||||
|
if (!article) {
|
||||||
|
return res.status(404).json({ error: `Artículo no encontrado en MongoDB: ${platform}:${idStr}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Actualizar favorito para el usuario autenticado
|
||||||
const articleData = JSON.parse(value);
|
await updateArticleFavorite(platform, idStr, true, username);
|
||||||
articleData.is_favorite = true;
|
|
||||||
// Mantener el TTL existente
|
|
||||||
const ttl = await redisClient.ttl(key);
|
|
||||||
if (ttl > 0) {
|
|
||||||
await redisClient.setex(key, ttl, JSON.stringify(articleData));
|
|
||||||
} else {
|
|
||||||
await redisClient.set(key, JSON.stringify(articleData));
|
|
||||||
}
|
|
||||||
|
|
||||||
const favorites = await getFavorites();
|
// Obtener favoritos del usuario autenticado (todos ven solo los suyos)
|
||||||
broadcast({ type: 'favorites_updated', data: favorites });
|
const favorites = await getFavorites(username);
|
||||||
|
broadcast({ type: 'favorites_updated', data: favorites, username });
|
||||||
res.json({ success: true, favorites });
|
res.json({ success: true, favorites });
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: 'Error procesando artículo' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error en POST /favorites:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -60,37 +64,35 @@ router.post('/', basicAuthMiddleware, async (req, res) => {
|
|||||||
// Eliminar favorito (requiere autenticación)
|
// Eliminar favorito (requiere autenticación)
|
||||||
router.delete('/:platform/:id', basicAuthMiddleware, async (req, res) => {
|
router.delete('/:platform/:id', basicAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const redisClient = getRedisClient();
|
const db = getDB();
|
||||||
if (!redisClient) {
|
if (!db) {
|
||||||
return res.status(500).json({ error: 'Redis no está disponible' });
|
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { platform, id } = req.params;
|
// Verificar que el usuario está autenticado (middleware ya lo valida, pero doble verificación)
|
||||||
const key = `notified:${platform}:${id}`;
|
if (!req.user || !req.user.username) {
|
||||||
const value = await redisClient.get(key);
|
return res.status(401).json({ error: 'Se requiere autenticación', message: 'Se requiere autenticación para eliminar favoritos' });
|
||||||
|
}
|
||||||
|
|
||||||
if (!value) {
|
const username = req.user.username;
|
||||||
|
const { platform, id } = req.params;
|
||||||
|
const idStr = String(id);
|
||||||
|
|
||||||
|
// Verificar si el artículo existe
|
||||||
|
const article = await getArticle(platform, idStr);
|
||||||
|
if (!article) {
|
||||||
return res.status(404).json({ error: 'Artículo no encontrado' });
|
return res.status(404).json({ error: 'Artículo no encontrado' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Actualizar favorito para el usuario autenticado
|
||||||
const articleData = JSON.parse(value);
|
await updateArticleFavorite(platform, idStr, false, username);
|
||||||
articleData.is_favorite = false;
|
|
||||||
// Mantener el TTL existente
|
|
||||||
const ttl = await redisClient.ttl(key);
|
|
||||||
if (ttl > 0) {
|
|
||||||
await redisClient.setex(key, ttl, JSON.stringify(articleData));
|
|
||||||
} else {
|
|
||||||
await redisClient.set(key, JSON.stringify(articleData));
|
|
||||||
}
|
|
||||||
|
|
||||||
const favorites = await getFavorites();
|
// Obtener favoritos del usuario autenticado (todos ven solo los suyos)
|
||||||
broadcast({ type: 'favorites_updated', data: favorites });
|
const favorites = await getFavorites(username);
|
||||||
|
broadcast({ type: 'favorites_updated', data: favorites, username });
|
||||||
res.json({ success: true, favorites });
|
res.json({ success: true, favorites });
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: 'Error procesando artículo' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error en DELETE /favorites:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,57 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { readJSON } from '../utils/fileUtils.js';
|
import { getFavorites, getNotifiedArticles, getDB, getWorkers, clearAllArticles } from '../services/mongodb.js';
|
||||||
import { PATHS } from '../config/constants.js';
|
|
||||||
import { getFavorites, getNotifiedArticles, getRedisClient } from '../services/redis.js';
|
|
||||||
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||||
|
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
|
||||||
import { broadcast } from '../services/websocket.js';
|
import { broadcast } from '../services/websocket.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Obtener estadísticas
|
// Obtener estadísticas (requiere autenticación obligatoria)
|
||||||
router.get('/stats', async (req, res) => {
|
router.get('/stats', basicAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const workers = readJSON(PATHS.WORKERS, { items: [] });
|
const db = getDB();
|
||||||
const favorites = await getFavorites();
|
if (!db) {
|
||||||
const notifiedArticles = await getNotifiedArticles();
|
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener usuario autenticado (requerido)
|
||||||
|
const user = req.user;
|
||||||
|
const isAdmin = user.role === 'admin';
|
||||||
|
|
||||||
|
let totalWorkers = 0;
|
||||||
|
let activeWorkers = 0;
|
||||||
|
let favorites = [];
|
||||||
|
let notifiedArticles = [];
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
// Admin: estadísticas globales de todos los usuarios
|
||||||
|
const workersCollection = db.collection('workers');
|
||||||
|
const allWorkers = await workersCollection.find({}).toArray();
|
||||||
|
|
||||||
|
for (const userWorkers of allWorkers) {
|
||||||
|
const items = userWorkers.items || [];
|
||||||
|
const disabled = userWorkers.disabled || [];
|
||||||
|
totalWorkers += items.length;
|
||||||
|
activeWorkers += items.filter(w => !disabled.includes(w.name) && !disabled.includes(w.id)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
favorites = await getFavorites(null); // Todos los favoritos
|
||||||
|
notifiedArticles = await getNotifiedArticles(); // Todos los artículos
|
||||||
|
} else {
|
||||||
|
// Usuario normal: solo sus estadísticas
|
||||||
|
const workers = await getWorkers(user.username);
|
||||||
|
const items = workers.items || [];
|
||||||
|
const disabled = workers.disabled || [];
|
||||||
|
totalWorkers = items.length;
|
||||||
|
activeWorkers = items.filter(w => !disabled.includes(w.name) && !disabled.includes(w.id)).length;
|
||||||
|
|
||||||
|
favorites = await getFavorites(user.username); // Solo sus favoritos
|
||||||
|
notifiedArticles = await getNotifiedArticles({ username: user.username }); // Solo sus artículos
|
||||||
|
}
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
totalWorkers: workers.items?.length || 0,
|
totalWorkers,
|
||||||
activeWorkers: (workers.items || []).filter(w => !workers.disabled?.includes(w.name)).length,
|
activeWorkers,
|
||||||
totalFavorites: favorites.length,
|
totalFavorites: favorites.length,
|
||||||
totalNotified: notifiedArticles.length,
|
totalNotified: notifiedArticles.length,
|
||||||
platforms: {
|
platforms: {
|
||||||
@@ -27,34 +62,21 @@ router.get('/stats', async (req, res) => {
|
|||||||
|
|
||||||
res.json(stats);
|
res.json(stats);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error obteniendo estadísticas:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Limpiar toda la caché de Redis (requiere autenticación)
|
// Limpiar toda la caché de MongoDB (requiere autenticación de administrador)
|
||||||
router.delete('/cache', basicAuthMiddleware, async (req, res) => {
|
router.delete('/cache', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const redisClient = getRedisClient();
|
const db = getDB();
|
||||||
if (!redisClient) {
|
if (!db) {
|
||||||
return res.status(500).json({ error: 'Redis no está disponible' });
|
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obtener todas las claves que empiezan con 'notified:'
|
// Eliminar todos los artículos
|
||||||
const keys = await redisClient.keys('notified:*');
|
const count = await clearAllArticles();
|
||||||
|
|
||||||
if (!keys || keys.length === 0) {
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Cache ya está vacío',
|
|
||||||
count: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eliminar todas las claves
|
|
||||||
const count = keys.length;
|
|
||||||
for (const key of keys) {
|
|
||||||
await redisClient.del(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notificar a los clientes WebSocket
|
// Notificar a los clientes WebSocket
|
||||||
broadcast({
|
broadcast({
|
||||||
@@ -62,17 +84,55 @@ router.delete('/cache', basicAuthMiddleware, async (req, res) => {
|
|||||||
data: { count, timestamp: Date.now() }
|
data: { count, timestamp: Date.now() }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// También notificar actualización de artículos (ahora está vacío)
|
||||||
|
broadcast({ type: 'articles_updated', data: [] });
|
||||||
|
|
||||||
// También actualizar favoritos (debería estar vacío ahora)
|
// También actualizar favoritos (debería estar vacío ahora)
|
||||||
const favorites = await getFavorites();
|
const favorites = await getFavorites(null);
|
||||||
broadcast({ type: 'favorites_updated', data: favorites });
|
broadcast({ type: 'favorites_updated', data: favorites, username: null });
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Cache limpiado: ${count} artículos eliminados`,
|
message: `Todos los artículos eliminados: ${count} artículos borrados`,
|
||||||
count
|
count
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error limpiando cache de Redis:', error);
|
console.error('Error limpiando cache de MongoDB:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Endpoint específico para borrar artículos (alias de /cache para claridad)
|
||||||
|
router.delete('/articles', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDB();
|
||||||
|
if (!db) {
|
||||||
|
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar todos los artículos
|
||||||
|
const count = await clearAllArticles();
|
||||||
|
|
||||||
|
// Notificar a los clientes WebSocket
|
||||||
|
broadcast({
|
||||||
|
type: 'articles_cleared',
|
||||||
|
data: { count, timestamp: Date.now() }
|
||||||
|
});
|
||||||
|
|
||||||
|
// También notificar actualización de artículos (ahora está vacío)
|
||||||
|
broadcast({ type: 'articles_updated', data: [] });
|
||||||
|
|
||||||
|
// También actualizar favoritos (debería estar vacío ahora)
|
||||||
|
const favorites = await getFavorites(null);
|
||||||
|
broadcast({ type: 'favorites_updated', data: favorites, username: null });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Todos los artículos eliminados: ${count} artículos borrados`,
|
||||||
|
count
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error borrando artículos:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||||
|
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
|
||||||
import { getLogPath, readLogs } from '../utils/fileUtils.js';
|
import { getLogPath, readLogs } from '../utils/fileUtils.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Obtener logs (últimas líneas o nuevas líneas desde un número de línea)
|
// Obtener logs (requiere autenticación de administrador obligatoria)
|
||||||
router.get('/', basicAuthMiddleware, (req, res) => {
|
router.get('/', basicAuthMiddleware, adminAuthMiddleware, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const logPath = getLogPath();
|
const logPath = getLogPath();
|
||||||
const sinceLine = parseInt(req.query.since) || 0;
|
const sinceLine = parseInt(req.query.since) || 0;
|
||||||
|
|||||||
@@ -1,27 +1,66 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||||
import { getConfig, reloadConfig } from '../services/redis.js';
|
import { getTelegramConfig, setTelegramConfig } from '../services/mongodb.js';
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import yaml from 'yaml';
|
|
||||||
import { PATHS } from '../config/constants.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Obtener configuración de Telegram del usuario autenticado
|
||||||
|
router.get('/config', basicAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const username = req.user.username;
|
||||||
|
const config = await getTelegramConfig(username);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return res.json({
|
||||||
|
token: '',
|
||||||
|
channel: '',
|
||||||
|
enable_polling: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(config);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error obteniendo configuración de Telegram:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Guardar configuración de Telegram del usuario autenticado
|
||||||
|
router.put('/config', basicAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const username = req.user.username;
|
||||||
|
const { token, channel, enable_polling } = req.body;
|
||||||
|
|
||||||
|
if (!token || !channel) {
|
||||||
|
return res.status(400).json({ error: 'Token y channel son requeridos' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setTelegramConfig(username, {
|
||||||
|
token,
|
||||||
|
channel,
|
||||||
|
enable_polling: enable_polling || false
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error guardando configuración de Telegram:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Obtener threads/topics de Telegram
|
// Obtener threads/topics de Telegram
|
||||||
router.get('/threads', basicAuthMiddleware, async (req, res) => {
|
router.get('/threads', basicAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
let config = getConfig();
|
const username = req.user.username;
|
||||||
if (!config) {
|
const config = await getTelegramConfig(username);
|
||||||
config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = config?.telegram_token;
|
if (!config || !config.token || !config.channel) {
|
||||||
const channel = config?.telegram_channel;
|
|
||||||
|
|
||||||
if (!token || !channel) {
|
|
||||||
return res.status(400).json({ error: 'Token o canal de Telegram no configurados' });
|
return res.status(400).json({ error: 'Token o canal de Telegram no configurados' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = config.token;
|
||||||
|
const channel = config.channel;
|
||||||
|
|
||||||
// Convertir el canal a chat_id si es necesario
|
// Convertir el canal a chat_id si es necesario
|
||||||
let chatId = channel;
|
let chatId = channel;
|
||||||
if (channel.startsWith('@')) {
|
if (channel.startsWith('@')) {
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import { getRedisClient } from '../services/redis.js';
|
import { getDB, getUser, createUser, deleteUser as deleteUserFromDB, getAllUsers, updateUserPassword } from '../services/mongodb.js';
|
||||||
import { basicAuthMiddleware, createSession, invalidateSession, invalidateUserSessions } from '../middlewares/auth.js';
|
import { basicAuthMiddleware, createSession, invalidateSession, invalidateUserSessions } from '../middlewares/auth.js';
|
||||||
|
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Endpoint de login (público)
|
// Endpoint de login (público)
|
||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const redisClient = getRedisClient();
|
const db = getDB();
|
||||||
if (!redisClient) {
|
if (!db) {
|
||||||
return res.status(500).json({ error: 'Redis no está disponible' });
|
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
@@ -19,17 +20,15 @@ router.post('/login', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'username y password son requeridos' });
|
return res.status(400).json({ error: 'username y password son requeridos' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buscar usuario en Redis
|
// Buscar usuario en MongoDB
|
||||||
const userKey = `user:${username}`;
|
const user = await getUser(username);
|
||||||
const userExists = await redisClient.exists(userKey);
|
|
||||||
|
|
||||||
if (!userExists) {
|
if (!user) {
|
||||||
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
|
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obtener hash de la contraseña
|
// Obtener hash de la contraseña
|
||||||
const userData = await redisClient.hGetAll(userKey);
|
const passwordHash = user.passwordHash;
|
||||||
const passwordHash = userData.passwordHash;
|
|
||||||
|
|
||||||
if (!passwordHash) {
|
if (!passwordHash) {
|
||||||
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
|
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
|
||||||
@@ -45,11 +44,15 @@ router.post('/login', async (req, res) => {
|
|||||||
// Crear sesión/token
|
// Crear sesión/token
|
||||||
const token = await createSession(username);
|
const token = await createSession(username);
|
||||||
|
|
||||||
console.log(`✅ Login exitoso: ${username}`);
|
// Obtener rol del usuario
|
||||||
|
const userRole = user.role || 'user';
|
||||||
|
|
||||||
|
console.log(`✅ Login exitoso: ${username} (${userRole})`);
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
token,
|
token,
|
||||||
username,
|
username,
|
||||||
|
role: userRole,
|
||||||
message: 'Login exitoso'
|
message: 'Login exitoso'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -78,9 +81,11 @@ router.post('/logout', basicAuthMiddleware, async (req, res) => {
|
|||||||
// Verificar token (para validar si la sesión sigue activa)
|
// Verificar token (para validar si la sesión sigue activa)
|
||||||
router.get('/me', basicAuthMiddleware, async (req, res) => {
|
router.get('/me', basicAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const user = await getUser(req.user.username);
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
username: req.user.username,
|
username: req.user.username,
|
||||||
|
role: user?.role || 'user',
|
||||||
authenticated: true
|
authenticated: true
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -91,9 +96,9 @@ router.get('/me', basicAuthMiddleware, async (req, res) => {
|
|||||||
// Cambiar contraseña de usuario
|
// Cambiar contraseña de usuario
|
||||||
router.post('/change-password', basicAuthMiddleware, async (req, res) => {
|
router.post('/change-password', basicAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const redisClient = getRedisClient();
|
const db = getDB();
|
||||||
if (!redisClient) {
|
if (!db) {
|
||||||
return res.status(500).json({ error: 'Redis no está disponible' });
|
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { currentPassword, newPassword } = req.body;
|
const { currentPassword, newPassword } = req.body;
|
||||||
@@ -107,26 +112,21 @@ router.post('/change-password', basicAuthMiddleware, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'La nueva contraseña debe tener al menos 6 caracteres' });
|
return res.status(400).json({ error: 'La nueva contraseña debe tener al menos 6 caracteres' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userKey = `user:${username}`;
|
const user = await getUser(username);
|
||||||
const userData = await redisClient.hGetAll(userKey);
|
|
||||||
|
|
||||||
if (!userData || !userData.passwordHash) {
|
if (!user || !user.passwordHash) {
|
||||||
return res.status(404).json({ error: 'Usuario no encontrado' });
|
return res.status(404).json({ error: 'Usuario no encontrado' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificar contraseña actual
|
// Verificar contraseña actual
|
||||||
const match = await bcrypt.compare(currentPassword, userData.passwordHash);
|
const match = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return res.status(401).json({ error: 'Contraseña actual incorrecta' });
|
return res.status(401).json({ error: 'Contraseña actual incorrecta' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hashear nueva contraseña y actualizar
|
// Hashear nueva contraseña y actualizar
|
||||||
const newPasswordHash = await bcrypt.hash(newPassword, 10);
|
const newPasswordHash = await bcrypt.hash(newPassword, 10);
|
||||||
await redisClient.hSet(userKey, {
|
await updateUserPassword(username, newPasswordHash);
|
||||||
...userData,
|
|
||||||
passwordHash: newPasswordHash,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invalidar todas las sesiones del usuario (requiere nuevo login)
|
// Invalidar todas las sesiones del usuario (requiere nuevo login)
|
||||||
await invalidateUserSessions(username);
|
await invalidateUserSessions(username);
|
||||||
@@ -139,40 +139,37 @@ router.post('/change-password', basicAuthMiddleware, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Obtener lista de usuarios (requiere autenticación admin)
|
// Obtener lista de usuarios (requiere autenticación, admin ve todos, user ve solo suyo)
|
||||||
router.get('/', basicAuthMiddleware, async (req, res) => {
|
router.get('/', basicAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const redisClient = getRedisClient();
|
const db = getDB();
|
||||||
if (!redisClient) {
|
if (!db) {
|
||||||
return res.status(500).json({ error: 'Redis no está disponible' });
|
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obtener todas las claves de usuarios
|
const users = await getAllUsers(req.user.username);
|
||||||
const userKeys = await redisClient.keys('user:*');
|
|
||||||
const users = [];
|
|
||||||
|
|
||||||
for (const key of userKeys) {
|
// Convertir ObjectId a string y formatear fechas
|
||||||
const username = key.replace('user:', '');
|
const formattedUsers = users.map(user => {
|
||||||
const userData = await redisClient.hGetAll(key);
|
const formatted = { ...user };
|
||||||
|
formatted._id = user._id?.toString();
|
||||||
if (userData && userData.username) {
|
if (user.createdAt && typeof user.createdAt === 'object') {
|
||||||
users.push({
|
formatted.createdAt = user.createdAt.toISOString();
|
||||||
username: userData.username,
|
}
|
||||||
createdAt: userData.createdAt || null,
|
if (user.updatedAt && typeof user.updatedAt === 'object') {
|
||||||
updatedAt: userData.updatedAt || null,
|
formatted.updatedAt = user.updatedAt.toISOString();
|
||||||
createdBy: userData.createdBy || null,
|
}
|
||||||
|
return formatted;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ordenar por fecha de creación (más recientes primero)
|
// Ordenar por fecha de creación (más recientes primero)
|
||||||
users.sort((a, b) => {
|
formattedUsers.sort((a, b) => {
|
||||||
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||||
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||||
return dateB - dateA;
|
return dateB - dateA;
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ users, total: users.length });
|
res.json({ users: formattedUsers, total: formattedUsers.length });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error obteniendo usuarios:', error);
|
console.error('Error obteniendo usuarios:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -180,11 +177,11 @@ router.get('/', basicAuthMiddleware, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Crear nuevo usuario (requiere autenticación admin)
|
// Crear nuevo usuario (requiere autenticación admin)
|
||||||
router.post('/', basicAuthMiddleware, async (req, res) => {
|
router.post('/', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const redisClient = getRedisClient();
|
const db = getDB();
|
||||||
if (!redisClient) {
|
if (!db) {
|
||||||
return res.status(500).json({ error: 'Redis no está disponible' });
|
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
@@ -201,19 +198,17 @@ router.post('/', basicAuthMiddleware, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'La contraseña debe tener al menos 6 caracteres' });
|
return res.status(400).json({ error: 'La contraseña debe tener al menos 6 caracteres' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userKey = `user:${username}`;
|
// Verificar si el usuario ya existe
|
||||||
const userExists = await redisClient.exists(userKey);
|
const existingUser = await getUser(username);
|
||||||
|
if (existingUser) {
|
||||||
if (userExists) {
|
|
||||||
return res.status(409).json({ error: 'El usuario ya existe' });
|
return res.status(409).json({ error: 'El usuario ya existe' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hashear contraseña y crear usuario
|
// Hashear contraseña y crear usuario
|
||||||
const passwordHash = await bcrypt.hash(password, 10);
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
await redisClient.hSet(userKey, {
|
await createUser({
|
||||||
username,
|
username,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
createdBy: req.user.username,
|
createdBy: req.user.username,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -221,16 +216,20 @@ router.post('/', basicAuthMiddleware, async (req, res) => {
|
|||||||
res.json({ success: true, message: 'Usuario creado correctamente', username });
|
res.json({ success: true, message: 'Usuario creado correctamente', username });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creando usuario:', error);
|
console.error('Error creando usuario:', error);
|
||||||
|
// Manejar error de duplicado
|
||||||
|
if (error.code === 11000) {
|
||||||
|
return res.status(409).json({ error: 'El usuario ya existe' });
|
||||||
|
}
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Eliminar usuario (requiere autenticación admin)
|
// Eliminar usuario (requiere autenticación admin)
|
||||||
router.delete('/:username', basicAuthMiddleware, async (req, res) => {
|
router.delete('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const redisClient = getRedisClient();
|
const db = getDB();
|
||||||
if (!redisClient) {
|
if (!db) {
|
||||||
return res.status(500).json({ error: 'Redis no está disponible' });
|
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { username } = req.params;
|
const { username } = req.params;
|
||||||
@@ -241,15 +240,19 @@ router.delete('/:username', basicAuthMiddleware, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'No puedes eliminar tu propio usuario' });
|
return res.status(400).json({ error: 'No puedes eliminar tu propio usuario' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userKey = `user:${username}`;
|
// Verificar si el usuario existe
|
||||||
const userExists = await redisClient.exists(userKey);
|
const user = await getUser(username);
|
||||||
|
if (!user) {
|
||||||
if (!userExists) {
|
|
||||||
return res.status(404).json({ error: 'Usuario no encontrado' });
|
return res.status(404).json({ error: 'Usuario no encontrado' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eliminar usuario
|
// Eliminar usuario y sus sesiones
|
||||||
await redisClient.del(userKey);
|
await deleteUserFromDB(username);
|
||||||
|
await invalidateUserSessions(username);
|
||||||
|
|
||||||
|
// También eliminar sus workers
|
||||||
|
const workersCollection = db.collection('workers');
|
||||||
|
await workersCollection.deleteOne({ username });
|
||||||
|
|
||||||
console.log(`✅ Usuario eliminado: ${username} por ${currentUser}`);
|
console.log(`✅ Usuario eliminado: ${username} por ${currentUser}`);
|
||||||
res.json({ success: true, message: `Usuario ${username} eliminado correctamente` });
|
res.json({ success: true, message: `Usuario ${username} eliminado correctamente` });
|
||||||
|
|||||||
@@ -1,32 +1,65 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { readJSON, writeJSON } from '../utils/fileUtils.js';
|
|
||||||
import { PATHS } from '../config/constants.js';
|
|
||||||
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||||
import { broadcast } from '../services/websocket.js';
|
import { broadcast } from '../services/websocket.js';
|
||||||
|
import { getWorkers, setWorkers, getDB } from '../services/mongodb.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Obtener workers (requiere autenticación - solo administradores)
|
// Obtener workers del usuario autenticado (requiere autenticación)
|
||||||
router.get('/', basicAuthMiddleware, (req, res) => {
|
router.get('/', basicAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const workers = readJSON(PATHS.WORKERS, { items: [], general: {}, disabled: [] });
|
const db = getDB();
|
||||||
|
if (!db) {
|
||||||
|
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = req.user.username;
|
||||||
|
const workers = await getWorkers(username);
|
||||||
res.json(workers);
|
res.json(workers);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error obteniendo workers:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Actualizar workers (requiere autenticación)
|
// Actualizar workers del usuario autenticado (requiere autenticación)
|
||||||
router.put('/', basicAuthMiddleware, (req, res) => {
|
router.put('/', basicAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const workers = req.body;
|
const db = getDB();
|
||||||
if (writeJSON(PATHS.WORKERS, workers)) {
|
if (!db) {
|
||||||
broadcast({ type: 'workers_updated', data: workers });
|
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||||
res.json({ success: true });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ error: 'Error guardando workers' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const username = req.user.username;
|
||||||
|
const workers = req.body;
|
||||||
|
|
||||||
|
// Validar estructura básica
|
||||||
|
if (!workers || typeof workers !== 'object') {
|
||||||
|
return res.status(400).json({ error: 'Formato de workers inválido' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asegurar estructura mínima
|
||||||
|
const workersData = {
|
||||||
|
general: workers.general || {
|
||||||
|
title_exclude: [],
|
||||||
|
description_exclude: []
|
||||||
|
},
|
||||||
|
items: workers.items || [],
|
||||||
|
disabled: workers.disabled || []
|
||||||
|
};
|
||||||
|
|
||||||
|
await setWorkers(username, workersData);
|
||||||
|
|
||||||
|
// Notificar a todos los clientes WebSocket del usuario
|
||||||
|
broadcast({
|
||||||
|
type: 'workers_updated',
|
||||||
|
data: workersData,
|
||||||
|
username // Incluir username para que los clientes sepan de quién son los workers
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error guardando workers:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import cors from 'cors';
|
|||||||
import { createServer } from 'http';
|
import { createServer } from 'http';
|
||||||
import { PATHS, SERVER } from './config/constants.js';
|
import { PATHS, SERVER } from './config/constants.js';
|
||||||
import { rateLimitMiddleware } from './middlewares/rateLimit.js';
|
import { rateLimitMiddleware } from './middlewares/rateLimit.js';
|
||||||
import { initRedis } from './services/redis.js';
|
import { initMongoDB } from './services/mongodb.js';
|
||||||
import { initVAPIDKeys } from './services/webPush.js';
|
import { initVAPIDKeys } from './services/webPush.js';
|
||||||
import { initWebSocket } from './services/websocket.js';
|
import { initWebSocket } from './services/websocket.js';
|
||||||
import { startArticleMonitoring } from './services/articleMonitor.js';
|
import { startArticleMonitoring } from './services/articleMonitor.js';
|
||||||
@@ -48,8 +48,8 @@ app.use('/api/users', usersRouter);
|
|||||||
// Inicializar servidor
|
// Inicializar servidor
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
try {
|
try {
|
||||||
// Inicializar Redis
|
// Inicializar MongoDB
|
||||||
await initRedis();
|
await initMongoDB();
|
||||||
|
|
||||||
// Iniciar monitoreo de artículos nuevos
|
// Iniciar monitoreo de artículos nuevos
|
||||||
await startArticleMonitoring();
|
await startArticleMonitoring();
|
||||||
|
|||||||
@@ -1,74 +1,57 @@
|
|||||||
import { getRedisClient, initNotifiedArticleKeys } from './redis.js';
|
import { getDB, initNotifiedArticleKeys } from './mongodb.js';
|
||||||
import { broadcast } from './websocket.js';
|
import { broadcast } from './websocket.js';
|
||||||
import { sendPushNotifications } from './webPush.js';
|
import { sendPushNotifications } from './webPush.js';
|
||||||
import { ARTICLE_MONITORING } from '../config/constants.js';
|
import { ARTICLE_MONITORING } from '../config/constants.js';
|
||||||
|
|
||||||
let notifiedArticleKeys = new Set();
|
let notifiedArticleIds = new Set();
|
||||||
let articlesCheckInterval = null;
|
let articlesCheckInterval = null;
|
||||||
|
|
||||||
// Función para detectar y enviar artículos nuevos
|
// Función para detectar y enviar artículos nuevos
|
||||||
async function checkForNewArticles() {
|
async function checkForNewArticles() {
|
||||||
const redisClient = getRedisClient();
|
const db = getDB();
|
||||||
if (!redisClient) {
|
if (!db) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentKeys = await redisClient.keys('notified:*');
|
const articlesCollection = db.collection('articles');
|
||||||
const currentKeysSet = new Set(currentKeys);
|
// Obtener todos los artículos con sus IDs
|
||||||
|
const allArticles = await articlesCollection.find(
|
||||||
|
{},
|
||||||
|
{ projection: { platform: 1, id: 1, title: 1, price: 1, currency: 1, url: 1, images: 1 } }
|
||||||
|
).toArray();
|
||||||
|
|
||||||
// Encontrar claves nuevas
|
const currentIds = new Set(
|
||||||
const newKeys = currentKeys.filter(key => !notifiedArticleKeys.has(key));
|
allArticles.map(a => `${a.platform}:${a.id}`)
|
||||||
|
);
|
||||||
|
|
||||||
if (newKeys.length > 0) {
|
// Encontrar artículos nuevos
|
||||||
// Obtener los artículos nuevos
|
const newArticles = allArticles.filter(article => {
|
||||||
const newArticles = [];
|
const articleId = `${article.platform}:${article.id}`;
|
||||||
for (const key of newKeys) {
|
return !notifiedArticleIds.has(articleId);
|
||||||
try {
|
|
||||||
const value = await redisClient.get(key);
|
|
||||||
if (value) {
|
|
||||||
// Intentar parsear como JSON
|
|
||||||
let articleData = {};
|
|
||||||
try {
|
|
||||||
articleData = JSON.parse(value);
|
|
||||||
} catch (e) {
|
|
||||||
// Si no es JSON válido, extraer información de la key
|
|
||||||
const parts = key.split(':');
|
|
||||||
if (parts.length >= 3) {
|
|
||||||
articleData = {
|
|
||||||
platform: parts[1],
|
|
||||||
id: parts.slice(2).join(':'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Añadir información adicional si está disponible
|
|
||||||
if (articleData.platform && articleData.id) {
|
|
||||||
newArticles.push({
|
|
||||||
platform: articleData.platform || 'unknown',
|
|
||||||
id: articleData.id || 'unknown',
|
|
||||||
title: articleData.title || null,
|
|
||||||
price: articleData.price || null,
|
|
||||||
currency: articleData.currency || '€',
|
|
||||||
url: articleData.url || null,
|
|
||||||
images: articleData.images || [],
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
if (newArticles.length > 0) {
|
||||||
} catch (error) {
|
// Preparar artículos para enviar
|
||||||
console.error(`Error obteniendo artículo de Redis (${key}):`, error.message);
|
const articlesToSend = newArticles.map(article => ({
|
||||||
}
|
platform: article.platform || 'unknown',
|
||||||
}
|
id: article.id || 'unknown',
|
||||||
|
title: article.title || null,
|
||||||
|
price: article.price || null,
|
||||||
|
currency: article.currency || '€',
|
||||||
|
url: article.url || null,
|
||||||
|
images: article.images || [],
|
||||||
|
}));
|
||||||
|
|
||||||
// Enviar artículos nuevos por WebSocket
|
// Enviar artículos nuevos por WebSocket
|
||||||
if (newArticles.length > 0) {
|
if (articlesToSend.length > 0) {
|
||||||
broadcast({
|
broadcast({
|
||||||
type: 'new_articles',
|
type: 'new_articles',
|
||||||
data: newArticles
|
data: articlesToSend
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enviar notificaciones push para cada artículo nuevo
|
// Enviar notificaciones push para cada artículo nuevo
|
||||||
for (const article of newArticles) {
|
for (const article of articlesToSend) {
|
||||||
await sendPushNotifications({
|
await sendPushNotifications({
|
||||||
title: `Nuevo artículo en ${article.platform?.toUpperCase() || 'Wallabicher'}`,
|
title: `Nuevo artículo en ${article.platform?.toUpperCase() || 'Wallabicher'}`,
|
||||||
body: article.title || 'Artículo nuevo disponible',
|
body: article.title || 'Artículo nuevo disponible',
|
||||||
@@ -83,20 +66,29 @@ async function checkForNewArticles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actualizar el set de claves notificadas
|
// Actualizar el set de IDs notificadas
|
||||||
notifiedArticleKeys = currentKeysSet;
|
notifiedArticleIds = currentIds;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error verificando artículos nuevos:', error.message);
|
console.error('Error verificando artículos nuevos:', error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inicializar el check de artículos cuando Redis esté listo
|
// Inicializar el check de artículos cuando MongoDB esté listo
|
||||||
export async function startArticleMonitoring() {
|
export async function startArticleMonitoring() {
|
||||||
const redisClient = getRedisClient();
|
const db = getDB();
|
||||||
if (redisClient) {
|
if (db) {
|
||||||
// Inicializar claves conocidas
|
// Inicializar IDs conocidas
|
||||||
notifiedArticleKeys = await initNotifiedArticleKeys();
|
const keys = await initNotifiedArticleKeys();
|
||||||
|
notifiedArticleIds = new Set(
|
||||||
|
Array.from(keys).map(key => {
|
||||||
|
const parts = key.replace('notified:', '').split(':');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return `${parts[0]}:${parts.slice(1).join(':')}`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Iniciar intervalo para verificar nuevos artículos
|
// Iniciar intervalo para verificar nuevos artículos
|
||||||
articlesCheckInterval = setInterval(checkForNewArticles, ARTICLE_MONITORING.CHECK_INTERVAL);
|
articlesCheckInterval = setInterval(checkForNewArticles, ARTICLE_MONITORING.CHECK_INTERVAL);
|
||||||
|
|||||||
@@ -1,34 +1,13 @@
|
|||||||
import { watch } from 'chokidar';
|
// File watcher ya no es necesario ya que los workers se almacenan en MongoDB
|
||||||
import { existsSync } from 'fs';
|
// Los cambios se notifican directamente a través de WebSocket cuando se actualizan via API
|
||||||
import { PATHS } from '../config/constants.js';
|
|
||||||
import { readJSON } from '../utils/fileUtils.js';
|
|
||||||
import { broadcast } from './websocket.js';
|
|
||||||
|
|
||||||
let watcher = null;
|
let watcher = null;
|
||||||
|
|
||||||
// Inicializar file watcher
|
// Inicializar file watcher (ahora vacío, mantenido para compatibilidad)
|
||||||
export function initFileWatcher() {
|
export function initFileWatcher() {
|
||||||
// Watch files for changes
|
// Los workers ahora se almacenan en MongoDB y se notifican directamente
|
||||||
const filesToWatch = [PATHS.WORKERS].filter(p => existsSync(p));
|
// a través de WebSocket cuando se actualizan via API
|
||||||
|
console.log('ℹ️ File watcher no es necesario (workers en MongoDB)');
|
||||||
if (filesToWatch.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
watcher = watch(filesToWatch, {
|
|
||||||
persistent: true,
|
|
||||||
ignoreInitial: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
watcher.on('change', async (path) => {
|
|
||||||
console.log(`Archivo cambiado: ${path}`);
|
|
||||||
if (path === PATHS.WORKERS) {
|
|
||||||
const workers = readJSON(PATHS.WORKERS);
|
|
||||||
broadcast({ type: 'workers_updated', data: workers });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ File watcher inicializado');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detener file watcher
|
// Detener file watcher
|
||||||
|
|||||||
879
web/backend/services/mongodb.js
Normal file
879
web/backend/services/mongodb.js
Normal file
@@ -0,0 +1,879 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// TTL de artículos notificados en milisegundos (7 días)
|
||||||
|
const NOTIFIED_ARTICLE_TTL = 7 * 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 });
|
||||||
|
|
||||||
|
// Í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 });
|
||||||
|
await db.collection('articles').createIndex({ 'user_info.notified_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 });
|
||||||
|
await db.collection('articles').createIndex({ notifiedAt: -1 });
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const articles = await articlesCollection
|
||||||
|
.find(query)
|
||||||
|
.sort({ 'user_info.notified_at': -1, createdAt: -1 })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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() || Date.now();
|
||||||
|
} 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;
|
||||||
|
result.is_favorite = firstUserInfo.is_favorite || false;
|
||||||
|
result.notifiedAt = firstUserInfo.notified_at?.getTime() || Date.now();
|
||||||
|
} 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() || Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}).filter(article => article !== null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error obteniendo artículos de 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({ 'user_info.notified_at': -1, createdAt: -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,
|
||||||
|
notifiedAt: userInfo.notified_at?.getTime() || Date.now(),
|
||||||
|
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,
|
||||||
|
notifiedAt: userInfo.notified_at?.getTime() || Date.now(),
|
||||||
|
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) {
|
||||||
|
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');
|
||||||
|
await sessionsCollection.insertOne({
|
||||||
|
token,
|
||||||
|
username,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Funciones para artículos
|
||||||
|
export async function saveArticle(articleData) {
|
||||||
|
if (!db) {
|
||||||
|
throw new Error('MongoDB no está disponible');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const articlesCollection = db.collection('articles');
|
||||||
|
const expiresAt = new Date(Date.now() + NOTIFIED_ARTICLE_TTL);
|
||||||
|
|
||||||
|
// Extraer datos del artículo (sin user_info)
|
||||||
|
const {
|
||||||
|
platform,
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
worker_name,
|
||||||
|
...articleFields
|
||||||
|
} = articleData;
|
||||||
|
|
||||||
|
// Buscar artículo existente
|
||||||
|
const existing = await articlesCollection.findOne({ platform, id });
|
||||||
|
|
||||||
|
// Preparar user_info para este usuario/worker
|
||||||
|
const userInfoEntry = {
|
||||||
|
username: username || null,
|
||||||
|
worker_name: worker_name || null,
|
||||||
|
notified: true,
|
||||||
|
notified_at: new Date(),
|
||||||
|
is_favorite: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Artículo existe, actualizar o añadir user_info
|
||||||
|
const existingUserInfo = existing.user_info || [];
|
||||||
|
|
||||||
|
// Buscar si ya existe un user_info para este usuario
|
||||||
|
const existingUserInfoIndex = existingUserInfo.findIndex(
|
||||||
|
ui => ui.username === username
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUserInfoIndex >= 0) {
|
||||||
|
// Actualizar user_info existente
|
||||||
|
existingUserInfo[existingUserInfoIndex] = {
|
||||||
|
...existingUserInfo[existingUserInfoIndex],
|
||||||
|
worker_name: worker_name || existingUserInfo[existingUserInfoIndex].worker_name,
|
||||||
|
notified: true,
|
||||||
|
notified_at: new Date(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Añadir nuevo user_info
|
||||||
|
existingUserInfo.push(userInfoEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
await articlesCollection.updateOne(
|
||||||
|
{ platform, id },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
...articleFields,
|
||||||
|
user_info: existingUserInfo,
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Artículo nuevo, crear con user_info
|
||||||
|
await articlesCollection.insertOne({
|
||||||
|
platform,
|
||||||
|
id,
|
||||||
|
...articleFields,
|
||||||
|
user_info: [userInfoEntry],
|
||||||
|
expiresAt,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error guardando artículo:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArticle(platform, id) {
|
||||||
|
if (!db) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const articlesCollection = db.collection('articles');
|
||||||
|
return await articlesCollection.findOne({ platform, id });
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAllArticles() {
|
||||||
|
if (!db) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const articlesCollection = db.collection('articles');
|
||||||
|
const result = await articlesCollection.deleteMany({});
|
||||||
|
return result.deletedCount;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error limpiando artículos:', 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@ import bcrypt from 'bcrypt';
|
|||||||
import { RateLimiterRedis } from 'rate-limiter-flexible';
|
import { RateLimiterRedis } from 'rate-limiter-flexible';
|
||||||
import { PATHS } from '../config/constants.js';
|
import { PATHS } from '../config/constants.js';
|
||||||
import { RATE_LIMIT } from '../config/constants.js';
|
import { RATE_LIMIT } from '../config/constants.js';
|
||||||
|
import { readJSON } from '../utils/fileUtils.js';
|
||||||
|
|
||||||
let redisClient = null;
|
let redisClient = null;
|
||||||
let rateLimiter = null;
|
let rateLimiter = null;
|
||||||
@@ -61,6 +62,9 @@ export async function initRedis() {
|
|||||||
// Inicializar usuario admin por defecto si no existe
|
// Inicializar usuario admin por defecto si no existe
|
||||||
await initDefaultAdmin();
|
await initDefaultAdmin();
|
||||||
|
|
||||||
|
// Migrar workers.json a Redis para admin si no existe
|
||||||
|
await migrateWorkersFromFile();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
console.log('ℹ️ Redis no configurado, usando modo memoria');
|
console.log('ℹ️ Redis no configurado, usando modo memoria');
|
||||||
console.log('⚠️ Rate limiting y autenticación requieren Redis');
|
console.log('⚠️ Rate limiting y autenticación requieren Redis');
|
||||||
@@ -157,6 +161,8 @@ export async function getNotifiedArticles() {
|
|||||||
url: articleData.url || null,
|
url: articleData.url || null,
|
||||||
images: articleData.images || [],
|
images: articleData.images || [],
|
||||||
modified_at: articleData.modified_at || null,
|
modified_at: articleData.modified_at || null,
|
||||||
|
username: articleData.username || null,
|
||||||
|
worker_name: articleData.worker_name || null,
|
||||||
notifiedAt: Date.now() - (7 * 24 * 60 * 60 - ttl) * 1000,
|
notifiedAt: Date.now() - (7 * 24 * 60 * 60 - ttl) * 1000,
|
||||||
expiresAt: Date.now() + ttl * 1000,
|
expiresAt: Date.now() + ttl * 1000,
|
||||||
});
|
});
|
||||||
@@ -217,3 +223,100 @@ export async function initNotifiedArticleKeys() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Funciones para manejar workers por usuario
|
||||||
|
export async function getWorkers(username) {
|
||||||
|
if (!redisClient) {
|
||||||
|
throw new Error('Redis no está disponible');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workersKey = `workers:${username}`;
|
||||||
|
const workersData = await redisClient.get(workersKey);
|
||||||
|
|
||||||
|
if (!workersData) {
|
||||||
|
// Retornar estructura vacía por defecto
|
||||||
|
return {
|
||||||
|
general: {
|
||||||
|
title_exclude: [],
|
||||||
|
description_exclude: []
|
||||||
|
},
|
||||||
|
items: [],
|
||||||
|
disabled: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(workersData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error obteniendo workers para ${username}:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setWorkers(username, workers) {
|
||||||
|
if (!redisClient) {
|
||||||
|
throw new Error('Redis no está disponible');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workersKey = `workers:${username}`;
|
||||||
|
const workersData = JSON.stringify(workers);
|
||||||
|
await redisClient.set(workersKey, workersData);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error guardando workers para ${username}:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrar workers.json a Redis para el usuario admin si no existe
|
||||||
|
async function migrateWorkersFromFile() {
|
||||||
|
if (!redisClient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adminWorkersKey = 'workers:admin';
|
||||||
|
const adminWorkersExists = await redisClient.exists(adminWorkersKey);
|
||||||
|
|
||||||
|
// Si ya existen workers para admin en Redis, no migrar
|
||||||
|
if (adminWorkersExists) {
|
||||||
|
console.log('ℹ️ Workers de admin ya existen en Redis, 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 = {
|
||||||
|
general: {
|
||||||
|
title_exclude: [],
|
||||||
|
description_exclude: []
|
||||||
|
},
|
||||||
|
items: [],
|
||||||
|
disabled: []
|
||||||
|
};
|
||||||
|
await setWorkers('admin', defaultWorkers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leer workers.json y migrar a Redis
|
||||||
|
const workersData = readJSON(PATHS.WORKERS, {
|
||||||
|
general: {
|
||||||
|
title_exclude: [],
|
||||||
|
description_exclude: []
|
||||||
|
},
|
||||||
|
items: [],
|
||||||
|
disabled: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Guardar en Redis para admin
|
||||||
|
await setWorkers('admin', workersData);
|
||||||
|
console.log(`✅ Workers migrados desde workers.json al usuario admin (${workersData.items?.length || 0} items)`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error migrando workers.json a Redis:', error.message);
|
||||||
|
// No lanzar error, solo registrar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,103 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div class="min-h-screen bg-gray-100 dark:bg-gray-900" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
||||||
<nav class="bg-white dark:bg-gray-800 shadow-lg dark:shadow-gray-900 border-b border-gray-200 dark:border-gray-700">
|
<!-- Sidebar -->
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<aside
|
||||||
<div class="flex justify-between h-16">
|
class="fixed top-0 left-0 z-40 h-screen transition-all duration-300 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 shadow-lg"
|
||||||
<div class="flex">
|
:class="sidebarCollapsed ? 'w-20' : 'w-64'"
|
||||||
<div class="flex-shrink-0 flex items-center">
|
>
|
||||||
<h1 class="text-xl sm:text-2xl font-bold text-primary-600 dark:text-primary-400">🛎️ Wallabicher</h1>
|
<!-- Logo -->
|
||||||
|
<div class="flex items-center justify-between h-16 px-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<router-link v-if="!sidebarCollapsed" to="/" class="flex items-center space-x-3 flex-1 min-w-0 group">
|
||||||
|
<div class="flex-shrink-0 w-10 h-10 rounded-lg overflow-hidden ring-2 ring-gray-200 dark:ring-gray-700 group-hover:ring-primary-500 transition-all">
|
||||||
|
<img
|
||||||
|
src="/logo.jpg"
|
||||||
|
alt="Wallabicher Logo"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden md:ml-6 md:flex md:space-x-8">
|
<div class="flex-1 min-w-0">
|
||||||
|
<h1 class="text-lg font-bold text-gray-900 dark:text-gray-100 truncate">Wallabicher</h1>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Admin Panel</p>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
<router-link v-else to="/" class="flex items-center justify-center w-full group">
|
||||||
|
<div class="w-10 h-10 rounded-lg overflow-hidden ring-2 ring-gray-200 dark:ring-gray-700 group-hover:ring-primary-500 transition-all">
|
||||||
|
<img
|
||||||
|
src="/logo.jpg"
|
||||||
|
alt="Wallabicher Logo"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
<button
|
||||||
|
@click="sidebarCollapsed = !sidebarCollapsed"
|
||||||
|
class="flex-shrink-0 p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ml-2"
|
||||||
|
:title="sidebarCollapsed ? 'Expandir sidebar' : 'Colapsar sidebar'"
|
||||||
|
>
|
||||||
|
<Bars3Icon v-if="sidebarCollapsed" class="w-5 h-5" />
|
||||||
|
<XMarkIcon v-else class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||||
<router-link
|
<router-link
|
||||||
v-for="item in navItems"
|
v-for="item in navItems"
|
||||||
:key="item.path"
|
:key="item.path"
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
class="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-900 dark:text-gray-200 hover:text-primary-600 dark:hover:text-primary-400 border-b-2 border-transparent hover:border-primary-600 dark:hover:border-primary-400 transition-colors"
|
class="flex items-center px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 group"
|
||||||
active-class="border-primary-600 dark:border-primary-400 text-primary-600 dark:text-primary-400"
|
:class="
|
||||||
|
$route.path === item.path
|
||||||
|
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-400 shadow-sm'
|
||||||
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<component :is="item.icon" class="w-5 h-5 mr-2" />
|
<component :is="item.icon" class="flex-shrink-0 w-5 h-5" :class="sidebarCollapsed ? 'mx-auto' : 'mr-3'" />
|
||||||
{{ item.name }}
|
<span v-if="!sidebarCollapsed" class="flex-1">{{ item.name }}</span>
|
||||||
|
<span
|
||||||
|
v-if="!sidebarCollapsed && $route.path === item.path"
|
||||||
|
class="ml-2 w-2 h-2 bg-primary-600 rounded-full"
|
||||||
|
></span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Sidebar Footer -->
|
||||||
|
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center space-x-2 mb-3" :class="sidebarCollapsed ? 'justify-center' : ''">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-8 h-8 bg-gradient-to-br from-green-400 to-green-500 rounded-full flex items-center justify-center">
|
||||||
|
<div class="w-3 h-3 bg-white rounded-full" :class="wsConnected ? 'opacity-100' : 'opacity-0'"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-3">
|
<div v-if="!sidebarCollapsed" class="flex-1 min-w-0">
|
||||||
|
<p class="text-xs font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{{ wsConnected ? 'Conectado' : 'Desconectado' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Estado del sistema</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="transition-all duration-300" :class="sidebarCollapsed ? 'ml-20' : 'ml-64'">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="sticky top-0 z-30 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between h-16 px-6">
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ getCurrentPageTitle() }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header Actions -->
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<!-- Push Notifications -->
|
||||||
<button
|
<button
|
||||||
v-if="!pushEnabled"
|
v-if="!pushEnabled"
|
||||||
@click="enablePushNotifications"
|
@click="enablePushNotifications"
|
||||||
class="p-2 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
class="relative p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
title="Activar notificaciones push"
|
title="Activar notificaciones push"
|
||||||
>
|
>
|
||||||
<BellIcon class="w-5 h-5" />
|
<BellIcon class="w-5 h-5" />
|
||||||
@@ -32,135 +105,77 @@
|
|||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
@click="disablePushNotifications"
|
@click="disablePushNotifications"
|
||||||
class="p-2 rounded-md text-green-600 dark:text-green-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
class="relative p-2 rounded-lg text-green-600 dark:text-green-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
title="Desactivar notificaciones push"
|
title="Desactivar notificaciones push"
|
||||||
>
|
>
|
||||||
<BellSlashIcon class="w-5 h-5" />
|
<BellSlashIcon class="w-5 h-5" />
|
||||||
|
<span class="absolute top-1 right-1 w-2 h-2 bg-green-500 rounded-full"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Dark Mode Toggle -->
|
||||||
<button
|
<button
|
||||||
@click="toggleDarkMode"
|
@click="toggleDarkMode"
|
||||||
class="p-2 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
class="p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
title="Alternar modo oscuro"
|
title="Alternar modo oscuro"
|
||||||
>
|
>
|
||||||
<SunIcon v-if="isDark" class="w-5 h-5" />
|
<SunIcon v-if="isDark" class="w-5 h-5" />
|
||||||
<MoonIcon v-else class="w-5 h-5" />
|
<MoonIcon v-else class="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Login/Logout -->
|
||||||
<button
|
<button
|
||||||
@click="isAuthenticated ? handleLogout() : showLoginModal = true"
|
@click="isAuthenticated ? handleLogout() : showLoginModal = true"
|
||||||
:class="[
|
class="p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
'p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500',
|
:title="isAuthenticated ? 'Desconectar' : 'Iniciar sesión'"
|
||||||
isAuthenticated
|
|
||||||
? 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
|
||||||
: 'text-primary-600 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20'
|
|
||||||
]"
|
|
||||||
:title="isAuthenticated ? 'Desconectar / Cerrar sesión' : 'Iniciar sesión'"
|
|
||||||
>
|
>
|
||||||
<ArrowRightOnRectangleIcon v-if="isAuthenticated" class="w-5 h-5" />
|
<ArrowRightOnRectangleIcon v-if="isAuthenticated" class="w-5 h-5" />
|
||||||
<ArrowLeftOnRectangleIcon v-else class="w-5 h-5" />
|
<ArrowLeftOnRectangleIcon v-else class="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<div class="hidden sm:flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
class="w-3 h-3 rounded-full"
|
|
||||||
:class="wsConnected ? 'bg-green-500' : 'bg-red-500'"
|
|
||||||
></div>
|
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{{ wsConnected ? 'Conectado' : 'Desconectado' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- Mobile menu button -->
|
|
||||||
<button
|
|
||||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
|
||||||
class="md:hidden inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
|
||||||
<Bars3Icon v-if="!mobileMenuOpen" class="block h-6 w-6" />
|
|
||||||
<XMarkIcon v-else class="block h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- Mobile menu -->
|
<!-- Page Content -->
|
||||||
<div v-if="mobileMenuOpen" class="md:hidden border-t border-gray-200 dark:border-gray-700">
|
<main class="p-6">
|
||||||
<div class="pt-2 pb-3 space-y-1 px-4">
|
|
||||||
<router-link
|
|
||||||
v-for="item in navItems"
|
|
||||||
:key="item.path"
|
|
||||||
:to="item.path"
|
|
||||||
@click="mobileMenuOpen = false"
|
|
||||||
class="flex items-center px-3 py-2 text-base font-medium text-gray-900 dark:text-gray-200 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-md transition-colors"
|
|
||||||
:class="$route.path === item.path ? 'text-primary-600 dark:text-primary-400 bg-gray-50 dark:bg-gray-700' : ''"
|
|
||||||
>
|
|
||||||
<component :is="item.icon" class="w-5 h-5 mr-3" />
|
|
||||||
{{ item.name }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
<div class="pt-4 pb-3 border-t border-gray-200 dark:border-gray-700 px-4 space-y-3">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
class="w-3 h-3 rounded-full"
|
|
||||||
:class="wsConnected ? 'bg-green-500' : 'bg-red-500'"
|
|
||||||
></div>
|
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{{ wsConnected ? 'Conectado' : 'Desconectado' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="isAuthenticated ? handleLogout() : showLoginModal = true"
|
|
||||||
:class="[
|
|
||||||
'w-full flex items-center px-3 py-2 text-base font-medium rounded-md transition-colors',
|
|
||||||
isAuthenticated
|
|
||||||
? 'text-gray-900 dark:text-gray-200 hover:text-red-600 dark:hover:text-red-400 hover:bg-gray-50 dark:hover:bg-gray-700'
|
|
||||||
: 'text-gray-900 dark:text-gray-200 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<ArrowRightOnRectangleIcon v-if="isAuthenticated" class="w-5 h-5 mr-3" />
|
|
||||||
<ArrowLeftOnRectangleIcon v-else class="w-5 h-5 mr-3" />
|
|
||||||
{{ isAuthenticated ? 'Desconectar' : 'Iniciar Sesión' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6">
|
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Toast notifications container -->
|
<!-- Toast notifications container -->
|
||||||
<div class="fixed top-4 right-4 z-50 space-y-2">
|
<div class="fixed top-20 right-6 z-50 space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="toast in toasts"
|
v-for="toast in toasts"
|
||||||
:key="toast.id"
|
:key="toast.id"
|
||||||
class="bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-lg shadow-md p-2 max-w-xs min-w-[200px] animate-slide-in"
|
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl p-3 max-w-sm min-w-[320px] animate-slide-in backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-3">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
v-if="toast.images"
|
v-if="toast.images"
|
||||||
:src="toast.images[0]"
|
:src="toast.images[0]"
|
||||||
:alt="toast.title"
|
:alt="toast.title"
|
||||||
class="w-10 h-10 object-cover rounded"
|
class="w-12 h-12 object-cover rounded-lg"
|
||||||
@error="($event) => $event.target.style.display = 'none'"
|
@error="($event) => $event.target.style.display = 'none'"
|
||||||
/>
|
/>
|
||||||
<div v-else class="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center">
|
<div v-else class="w-12 h-12 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600 rounded-lg flex items-center justify-center">
|
||||||
<span class="text-gray-400 text-xs">📦</span>
|
<span class="text-gray-400 text-lg">📦</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-start justify-between gap-1">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-1 mb-0.5">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<span
|
<span
|
||||||
class="px-1.5 py-0.5 text-[10px] font-semibold rounded"
|
class="px-2 py-0.5 text-[10px] font-bold rounded-md uppercase tracking-wide"
|
||||||
:class="toast.platform === 'wallapop' ? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200' : 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
|
:class="toast.platform === 'wallapop' ? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300' : 'bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300'"
|
||||||
>
|
>
|
||||||
{{ toast.platform?.toUpperCase() }}
|
{{ toast.platform?.toUpperCase() }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 class="font-medium text-gray-900 dark:text-gray-100 text-xs mb-0.5 line-clamp-1 leading-tight">
|
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm mb-1 line-clamp-1 leading-tight">
|
||||||
{{ toast.title || 'Nuevo artículo' }}
|
{{ toast.title || 'Nuevo artículo' }}
|
||||||
</h4>
|
</h4>
|
||||||
<p v-if="toast.price" class="text-sm font-bold text-primary-600 dark:text-primary-400 mb-1">
|
<p v-if="toast.price" class="text-base font-bold text-primary-600 dark:text-primary-400 mb-2">
|
||||||
{{ toast.price }} {{ toast.currency || '€' }}
|
{{ toast.price }} {{ toast.currency || '€' }}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
@@ -168,14 +183,14 @@
|
|||||||
:href="toast.url"
|
:href="toast.url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-[10px] text-primary-600 dark:text-primary-400 hover:underline inline-flex items-center gap-0.5"
|
class="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline inline-flex items-center gap-1 font-medium"
|
||||||
>
|
>
|
||||||
Ver →
|
Ver artículo →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="removeToast(toast.id)"
|
@click="removeToast(toast.id)"
|
||||||
class="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xs leading-none p-0.5"
|
class="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-sm leading-none p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||||
title="Cerrar"
|
title="Cerrar"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
@@ -189,32 +204,52 @@
|
|||||||
<!-- Modal de Login Global -->
|
<!-- Modal de Login Global -->
|
||||||
<div
|
<div
|
||||||
v-if="showLoginModal"
|
v-if="showLoginModal"
|
||||||
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
|
class="fixed inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||||
@click.self="closeLoginModal"
|
@click.self="closeLoginModal"
|
||||||
>
|
>
|
||||||
<div class="card max-w-md w-full">
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-md w-full border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<!-- Modal Header -->
|
||||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">🔐 Iniciar Sesión</h2>
|
<div class="bg-gradient-to-r from-primary-600 to-primary-700 px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-10 h-10 bg-white rounded-lg overflow-hidden ring-2 ring-white/30">
|
||||||
|
<img
|
||||||
|
src="/logo.jpg"
|
||||||
|
alt="Wallabicher Logo"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-white">Iniciar Sesión</h2>
|
||||||
|
<p class="text-xs text-white/80">Wallabicher Admin Panel</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="closeLoginModal"
|
@click="closeLoginModal"
|
||||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
class="text-white/80 hover:text-white p-1 rounded-lg hover:bg-white/20 transition-colors"
|
||||||
title="Cerrar"
|
title="Cerrar"
|
||||||
>
|
>
|
||||||
✕
|
<XMarkIcon class="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
<!-- Modal Body -->
|
||||||
Ingresa tus credenciales para acceder a las funciones de administrador.
|
<div class="px-6 py-6">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
Ingresa tus credenciales para acceder al panel de administración.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form @submit.prevent="handleGlobalLogin" class="space-y-4">
|
<form @submit.prevent="handleGlobalLogin" class="space-y-5">
|
||||||
<div v-if="globalLoginError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
<div v-if="globalLoginError" class="bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500 text-red-700 dark:text-red-300 px-4 py-3 rounded-r-lg">
|
||||||
{{ globalLoginError }}
|
<div class="flex items-center">
|
||||||
|
<span class="text-red-500 mr-2">⚠️</span>
|
||||||
|
<span class="text-sm font-medium">{{ globalLoginError }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Usuario
|
Usuario
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -228,7 +263,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Contraseña
|
Contraseña
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -253,7 +288,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div class="flex flex-col-reverse sm:flex-row justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="closeLoginModal"
|
@click="closeLoginModal"
|
||||||
@@ -266,15 +301,14 @@
|
|||||||
class="btn btn-primary text-sm sm:text-base"
|
class="btn btn-primary text-sm sm:text-base"
|
||||||
:disabled="globalLoginLoading"
|
:disabled="globalLoginLoading"
|
||||||
>
|
>
|
||||||
{{ globalLoginLoading ? 'Iniciando...' : 'Iniciar Sesión' }}
|
<span v-if="!globalLoginLoading">Iniciar Sesión</span>
|
||||||
|
<span v-else class="flex items-center">
|
||||||
|
<span class="animate-spin mr-2">⏳</span>
|
||||||
|
Iniciando...
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
💡 Por defecto: usuario <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">admin</code> / contraseña <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">admin</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,24 +338,26 @@ import authService from './services/auth';
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import api from './services/api';
|
import api from './services/api';
|
||||||
|
|
||||||
const navItems = [
|
const allNavItems = [
|
||||||
{ path: '/', name: 'Dashboard', icon: HomeIcon },
|
{ path: '/', name: 'Dashboard', icon: HomeIcon, adminOnly: false },
|
||||||
{ path: '/articles', name: 'Artículos', icon: DocumentTextIcon },
|
{ path: '/articles', name: 'Artículos', icon: DocumentTextIcon, adminOnly: false },
|
||||||
{ path: '/favorites', name: 'Favoritos', icon: HeartIcon },
|
{ path: '/favorites', name: 'Favoritos', icon: HeartIcon, adminOnly: false },
|
||||||
{ path: '/workers', name: 'Workers', icon: Cog6ToothIcon },
|
{ path: '/workers', name: 'Workers', icon: Cog6ToothIcon, adminOnly: false },
|
||||||
{ path: '/users', name: 'Usuarios', icon: UserGroupIcon },
|
{ path: '/users', name: 'Usuarios', icon: UserGroupIcon, adminOnly: false },
|
||||||
{ path: '/logs', name: 'Logs', icon: DocumentMagnifyingGlassIcon },
|
{ path: '/logs', name: 'Logs', icon: DocumentMagnifyingGlassIcon, adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const wsConnected = ref(false);
|
const wsConnected = ref(false);
|
||||||
const mobileMenuOpen = ref(false);
|
const sidebarCollapsed = ref(false);
|
||||||
const darkMode = ref(false);
|
const darkMode = ref(false);
|
||||||
const toasts = ref([]);
|
const toasts = ref([]);
|
||||||
const pushEnabled = ref(false);
|
const pushEnabled = ref(false);
|
||||||
const showLoginModal = ref(false);
|
const showLoginModal = ref(false);
|
||||||
const globalLoginError = ref('');
|
const globalLoginError = ref('');
|
||||||
const globalLoginLoading = ref(false);
|
const globalLoginLoading = ref(false);
|
||||||
|
const currentUser = ref(authService.getUsername() || null);
|
||||||
|
const isAdmin = ref(false);
|
||||||
const globalLoginForm = ref({
|
const globalLoginForm = ref({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
@@ -333,6 +369,22 @@ let toastIdCounter = 0;
|
|||||||
const isDark = computed(() => darkMode.value);
|
const isDark = computed(() => darkMode.value);
|
||||||
const isAuthenticated = computed(() => authService.hasCredentials());
|
const isAuthenticated = computed(() => authService.hasCredentials());
|
||||||
|
|
||||||
|
// Filtrar navItems según el rol del usuario
|
||||||
|
const navItems = computed(() => {
|
||||||
|
return allNavItems.filter(item => {
|
||||||
|
// Si requiere admin y el usuario no es admin, excluir
|
||||||
|
if (item.adminOnly && !isAdmin.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function checkUserRole() {
|
||||||
|
currentUser.value = authService.getUsername() || null;
|
||||||
|
isAdmin.value = currentUser.value === 'admin'; // Simplificación temporal
|
||||||
|
}
|
||||||
|
|
||||||
function addToast(article) {
|
function addToast(article) {
|
||||||
const id = ++toastIdCounter;
|
const id = ++toastIdCounter;
|
||||||
toasts.value.push({
|
toasts.value.push({
|
||||||
@@ -465,6 +517,15 @@ function handleAuthRequired(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCurrentPageTitle() {
|
||||||
|
const currentItem = navItems.value.find(item => item.path === router.currentRoute.value.path);
|
||||||
|
return currentItem ? currentItem.name : 'Dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthChange() {
|
||||||
|
checkUserRole();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
// Llamar al endpoint de logout e invalidar token
|
// Llamar al endpoint de logout e invalidar token
|
||||||
await authService.logout();
|
await authService.logout();
|
||||||
@@ -481,6 +542,7 @@ async function handleLogout() {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
initDarkMode();
|
initDarkMode();
|
||||||
|
checkUserRole();
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
await checkPushStatus();
|
await checkPushStatus();
|
||||||
|
|
||||||
@@ -496,15 +558,20 @@ onMounted(async () => {
|
|||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
// Si el token expiró, limpiar sesión
|
// Si el token expiró, limpiar sesión
|
||||||
authService.clearSession();
|
authService.clearSession();
|
||||||
|
checkUserRole();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escuchar eventos de autenticación requerida
|
// Escuchar eventos de autenticación requerida
|
||||||
window.addEventListener('auth-required', handleAuthRequired);
|
window.addEventListener('auth-required', handleAuthRequired);
|
||||||
|
window.addEventListener('auth-login', handleAuthChange);
|
||||||
|
window.addEventListener('auth-logout', handleAuthChange);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('auth-required', handleAuthRequired);
|
window.removeEventListener('auth-required', handleAuthRequired);
|
||||||
|
window.removeEventListener('auth-login', handleAuthChange);
|
||||||
|
window.removeEventListener('auth-logout', handleAuthChange);
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.close();
|
ws.close();
|
||||||
}
|
}
|
||||||
|
|||||||
225
web/frontend/src/components/ArticleCard.vue
Normal file
225
web/frontend/src/components/ArticleCard.vue
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card hover:shadow-lg transition-shadow">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||||
|
<!-- Imagen del artículo -->
|
||||||
|
<div class="flex-shrink-0 self-center sm:self-start">
|
||||||
|
<div v-if="article.images && article.images.length > 0" class="w-24 h-24 sm:w-32 sm:h-32 relative">
|
||||||
|
<img
|
||||||
|
:src="article.images[0]"
|
||||||
|
:alt="article.title || 'Sin título'"
|
||||||
|
class="w-24 h-24 sm:w-32 sm:h-32 object-cover rounded-lg"
|
||||||
|
@error="($event) => handleImageError($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-24 h-24 sm:w-32 sm:h-32 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500 text-xs">Sin imagen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Información del artículo -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||||
|
<span
|
||||||
|
class="px-2 py-1 text-xs font-semibold rounded flex-shrink-0"
|
||||||
|
:class="
|
||||||
|
article.platform === 'wallapop'
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300'
|
||||||
|
: 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-300'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ article.platform?.toUpperCase() || 'N/A' }}
|
||||||
|
</span>
|
||||||
|
<span v-if="article.username" class="px-2 py-1 text-xs font-medium rounded bg-purple-100 dark:bg-purple-900/50 text-purple-800 dark:text-purple-300 flex-shrink-0" title="Usuario">
|
||||||
|
👤 {{ article.username }}
|
||||||
|
</span>
|
||||||
|
<span v-if="article.worker_name" class="px-2 py-1 text-xs font-medium rounded bg-orange-100 dark:bg-orange-900/50 text-orange-800 dark:text-orange-300 flex-shrink-0" title="Worker">
|
||||||
|
⚙️ {{ article.worker_name }}
|
||||||
|
</span>
|
||||||
|
<span v-if="article.notifiedAt" class="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ formatDate(article.notifiedAt) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="article.addedAt && !article.notifiedAt" class="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Añadido: {{ formatDate(article.addedAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1 line-clamp-2" :title="article.title">
|
||||||
|
{{ article.title || 'Sin título' }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div v-if="article.price !== null && article.price !== undefined" class="mb-2">
|
||||||
|
<span class="text-xl font-bold text-primary-600 dark:text-primary-400">
|
||||||
|
{{ article.price }} {{ article.currency || '€' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1 text-xs sm:text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
<div v-if="article.location" class="flex flex-wrap items-center">
|
||||||
|
<span class="font-medium">📍 Localidad:</span>
|
||||||
|
<span class="ml-2">{{ article.location }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="article.allows_shipping !== null && article.allows_shipping !== undefined" class="flex flex-wrap items-center">
|
||||||
|
<span class="font-medium">🚚 Envío:</span>
|
||||||
|
<span class="ml-2">{{ article.allows_shipping ? '✅ Acepta envíos' : '❌ No acepta envíos' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="article.modified_at" class="flex flex-wrap items-center">
|
||||||
|
<span class="font-medium">🕒 Modificado:</span>
|
||||||
|
<span class="ml-2 break-all">{{ article.modified_at }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="article.description" class="text-xs sm:text-sm text-gray-700 dark:text-gray-300 mb-2 overflow-hidden line-clamp-2">
|
||||||
|
{{ article.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 sm:gap-4 mt-3">
|
||||||
|
<a
|
||||||
|
v-if="article.url"
|
||||||
|
:href="article.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 text-xs sm:text-sm font-medium break-all"
|
||||||
|
>
|
||||||
|
🔗 Ver anuncio
|
||||||
|
</a>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500 break-all">
|
||||||
|
ID: {{ article.id }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="showRemoveButton"
|
||||||
|
@click="$emit('remove', article.platform, article.id)"
|
||||||
|
class="btn btn-danger text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!showRemoveButton && isAuthenticated && !isAdding"
|
||||||
|
@click="handleAddFavorite"
|
||||||
|
class="btn text-xs sm:text-sm flex items-center gap-1"
|
||||||
|
:class="favoriteStatus ? 'btn-secondary' : 'bg-pink-500 hover:bg-pink-600 text-white border-pink-600'"
|
||||||
|
:disabled="favoriteStatus"
|
||||||
|
:title="favoriteStatus ? 'Ya está en favoritos' : 'Añadir a favoritos'"
|
||||||
|
>
|
||||||
|
<HeartIconSolid v-if="favoriteStatus" class="w-4 h-4" />
|
||||||
|
<HeartIcon v-else class="w-4 h-4" />
|
||||||
|
{{ favoriteStatus ? 'En favoritos' : 'Añadir a favoritos' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!showRemoveButton && isAuthenticated && isAdding"
|
||||||
|
disabled
|
||||||
|
class="btn btn-secondary text-xs sm:text-sm opacity-50 cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span class="inline-block animate-spin mr-1">⏳</span>
|
||||||
|
Añadiendo...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps, defineEmits, ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { HeartIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { HeartIcon as HeartIconSolid } from '@heroicons/vue/24/solid';
|
||||||
|
import authService from '../services/auth';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
article: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
showRemoveButton: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isFavorite: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['remove', 'added']);
|
||||||
|
|
||||||
|
const isAdding = ref(false);
|
||||||
|
const isAuthenticated = ref(false);
|
||||||
|
const favoriteStatus = ref(props.isFavorite);
|
||||||
|
|
||||||
|
// Verificar autenticación al montar y cuando cambie
|
||||||
|
function checkAuth() {
|
||||||
|
isAuthenticated.value = authService.hasCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(timestamp) {
|
||||||
|
if (!timestamp) return 'N/A';
|
||||||
|
return new Date(timestamp).toLocaleString('es-ES');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageError(event) {
|
||||||
|
// Si la imagen falla al cargar, reemplazar con placeholder
|
||||||
|
event.target.onerror = null; // Prevenir bucle infinito
|
||||||
|
event.target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgdmlld0JveD0iMCAwIDEyOCAxMjgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik00OCA0OEg4ME04MCA4MEg0OE00OCA0OEw2NCA2NEw4MCA0OE00OCA4MEw2NCA2NE04MCA4MEw2NCA2NEw0OCA4MCIgc3Ryb2tlPSIjOUI5Q0E0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddFavorite() {
|
||||||
|
if (!isAuthenticated.value || favoriteStatus.value || isAdding.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.article.platform || !props.article.id) {
|
||||||
|
alert('Error: El artículo no tiene platform o id válidos');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdding.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// El backend solo necesita platform e id
|
||||||
|
const favorite = {
|
||||||
|
platform: props.article.platform,
|
||||||
|
id: String(props.article.id), // Asegurar que sea string
|
||||||
|
};
|
||||||
|
|
||||||
|
await api.addFavorite(favorite);
|
||||||
|
favoriteStatus.value = true;
|
||||||
|
|
||||||
|
// Emitir evento para que el componente padre pueda actualizar si es necesario
|
||||||
|
emit('added', props.article.platform, props.article.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error añadiendo a favoritos:', error);
|
||||||
|
// El interceptor de API ya maneja el error 401 mostrando el modal de login
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
alert('El artículo no se encontró en la base de datos. Asegúrate de que el artículo esté en la lista de notificados.');
|
||||||
|
} else if (error.response?.status === 400) {
|
||||||
|
alert('Error: ' + (error.response?.data?.error || 'Datos inválidos'));
|
||||||
|
} else if (error.response?.status !== 401) {
|
||||||
|
const errorMessage = error.response?.data?.error || error.message || 'Error desconocido';
|
||||||
|
alert('Error al añadir a favoritos: ' + errorMessage);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isAdding.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthChange() {
|
||||||
|
checkAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkAuth();
|
||||||
|
// Escuchar cambios en la autenticación
|
||||||
|
window.addEventListener('auth-login', handleAuthChange);
|
||||||
|
window.addEventListener('auth-logout', handleAuthChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('auth-login', handleAuthChange);
|
||||||
|
window.removeEventListener('auth-logout', handleAuthChange);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
@@ -74,10 +74,9 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Artículos
|
// Artículos
|
||||||
async getArticles(limit = 100, offset = 0) {
|
async getArticles(limit = 100, offset = 0, additionalParams = {}) {
|
||||||
const response = await api.get('/articles', {
|
const params = { limit, offset, ...additionalParams };
|
||||||
params: { limit, offset },
|
const response = await api.get('/articles', { params });
|
||||||
});
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -105,6 +104,16 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Telegram
|
// Telegram
|
||||||
|
async getTelegramConfig() {
|
||||||
|
const response = await api.get('/telegram/config');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async setTelegramConfig(config) {
|
||||||
|
const response = await api.put('/telegram/config', config);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
async getTelegramThreads() {
|
async getTelegramThreads() {
|
||||||
const response = await api.get('/telegram/threads');
|
const response = await api.get('/telegram/threads');
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -116,6 +125,12 @@ export default {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Artículos - Borrar todos (solo admin)
|
||||||
|
async clearAllArticles() {
|
||||||
|
const response = await api.delete('/articles');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Usuarios
|
// Usuarios
|
||||||
async getUsers() {
|
async getUsers() {
|
||||||
const response = await api.get('/users');
|
const response = await api.get('/users');
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
const AUTH_STORAGE_KEY = 'wallabicher_token';
|
const AUTH_STORAGE_KEY = 'wallabicher_token';
|
||||||
const USERNAME_STORAGE_KEY = 'wallabicher_username';
|
const USERNAME_STORAGE_KEY = 'wallabicher_username';
|
||||||
|
const ROLE_STORAGE_KEY = 'wallabicher_role';
|
||||||
|
|
||||||
class AuthService {
|
class AuthService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.token = this.loadToken();
|
this.token = this.loadToken();
|
||||||
this.username = this.loadUsername();
|
this.username = this.loadUsername();
|
||||||
|
this.role = this.loadRole();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cargar token desde localStorage
|
// Cargar token desde localStorage
|
||||||
@@ -29,13 +31,15 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guardar token y username en localStorage
|
// Guardar token, username y role en localStorage
|
||||||
saveSession(token, username) {
|
saveSession(token, username, role = 'user') {
|
||||||
try {
|
try {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.username = username;
|
this.username = username;
|
||||||
|
this.role = role;
|
||||||
localStorage.setItem(AUTH_STORAGE_KEY, token);
|
localStorage.setItem(AUTH_STORAGE_KEY, token);
|
||||||
localStorage.setItem(USERNAME_STORAGE_KEY, username);
|
localStorage.setItem(USERNAME_STORAGE_KEY, username);
|
||||||
|
localStorage.setItem(ROLE_STORAGE_KEY, role);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error guardando sesión:', error);
|
console.error('Error guardando sesión:', error);
|
||||||
@@ -43,13 +47,25 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eliminar token y username
|
// Cargar role desde localStorage
|
||||||
|
loadRole() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(ROLE_STORAGE_KEY) || 'user';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cargando role:', error);
|
||||||
|
return 'user';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar token, username y role
|
||||||
clearSession() {
|
clearSession() {
|
||||||
try {
|
try {
|
||||||
this.token = '';
|
this.token = '';
|
||||||
this.username = '';
|
this.username = '';
|
||||||
|
this.role = 'user';
|
||||||
localStorage.removeItem(AUTH_STORAGE_KEY);
|
localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||||
localStorage.removeItem(USERNAME_STORAGE_KEY);
|
localStorage.removeItem(USERNAME_STORAGE_KEY);
|
||||||
|
localStorage.removeItem(ROLE_STORAGE_KEY);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error eliminando sesión:', error);
|
console.error('Error eliminando sesión:', error);
|
||||||
@@ -67,6 +83,16 @@ class AuthService {
|
|||||||
return this.username;
|
return this.username;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Obtener role actual
|
||||||
|
getRole() {
|
||||||
|
return this.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar si es admin
|
||||||
|
isAdmin() {
|
||||||
|
return this.role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
// Verificar si hay sesión activa (token guardado)
|
// Verificar si hay sesión activa (token guardado)
|
||||||
hasCredentials() {
|
hasCredentials() {
|
||||||
return !!this.token;
|
return !!this.token;
|
||||||
@@ -98,8 +124,9 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.success && data.token) {
|
if (data.success && data.token) {
|
||||||
this.saveSession(data.token, data.username);
|
const role = data.role || 'user';
|
||||||
return { success: true, token: data.token, username: data.username };
|
this.saveSession(data.token, data.username, role);
|
||||||
|
return { success: true, token: data.token, username: data.username, role };
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Respuesta inválida del servidor');
|
throw new Error('Respuesta inválida del servidor');
|
||||||
@@ -155,6 +182,11 @@ class AuthService {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success && data.authenticated) {
|
if (data.success && data.authenticated) {
|
||||||
|
// Actualizar role si está disponible
|
||||||
|
if (data.role) {
|
||||||
|
this.role = data.role;
|
||||||
|
localStorage.setItem(ROLE_STORAGE_KEY, data.role);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,43 +3,175 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-gray-200 dark:border-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100;
|
@apply bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
@apply w-2 h-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-gray-100 dark:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-gray-400 dark:bg-gray-500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
/* Metronic-style Cards */
|
||||||
.card {
|
.card {
|
||||||
@apply bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-lg p-6 border border-gray-200 dark:border-gray-700;
|
@apply bg-white dark:bg-gray-800 rounded-xl shadow-sm dark:shadow-lg p-6 border border-gray-200 dark:border-gray-700 transition-shadow duration-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
@apply shadow-md dark:shadow-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
@apply border-b border-gray-200 dark:border-gray-700 pb-4 mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
@apply text-lg font-bold text-gray-900 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-subtitle {
|
||||||
|
@apply text-sm text-gray-500 dark:text-gray-400 mt-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
.btn {
|
.btn {
|
||||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
|
@apply px-4 py-2.5 rounded-lg font-semibold text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply bg-primary-600 dark:bg-primary-500 text-white hover:bg-primary-700 dark:hover:bg-primary-600;
|
@apply bg-primary-600 dark:bg-primary-500 text-white hover:bg-primary-700 dark:hover:bg-primary-600 focus:ring-primary-500 shadow-sm hover:shadow-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@apply bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600;
|
@apply bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 focus:ring-gray-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@apply bg-red-600 dark:bg-red-700 text-white hover:bg-red-700 dark:hover:bg-red-800;
|
@apply bg-red-600 dark:bg-red-700 text-white hover:bg-red-700 dark:hover:bg-red-800 focus:ring-red-500 shadow-sm hover:shadow-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
@apply bg-green-600 dark:bg-green-700 text-white hover:bg-green-700 dark:hover:bg-green-800 focus:ring-green-500 shadow-sm hover:shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
@apply px-3 py-1.5 text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
@apply px-6 py-3 text-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
.input {
|
.input {
|
||||||
@apply w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400;
|
@apply w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
@apply shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-primary {
|
||||||
|
@apply bg-primary-100 dark:bg-primary-900/50 text-primary-700 dark:text-primary-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
@apply bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
@apply bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
@apply bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
@apply bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Cards */
|
||||||
|
.stat-card {
|
||||||
|
@apply card relative overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card::before {
|
||||||
|
content: '';
|
||||||
|
@apply absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-primary-500/10 to-primary-600/10 rounded-full -mr-10 -mt-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table {
|
||||||
|
@apply w-full border-collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table thead {
|
||||||
|
@apply bg-gray-50 dark:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
@apply px-6 py-3 text-left text-xs font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wider border-b border-gray-200 dark:border-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 border-b border-gray-200 dark:border-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover {
|
||||||
|
@apply bg-gray-50 dark:bg-gray-800/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Line clamp utility */
|
/* Line clamp utility */
|
||||||
|
.line-clamp-1 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.line-clamp-2 {
|
.line-clamp-2 {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.line-clamp-3 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar transitions */
|
||||||
|
.sidebar-collapsed {
|
||||||
|
transition: margin-left 0.3s ease;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-in {
|
@keyframes slide-in {
|
||||||
@@ -53,7 +185,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-slide-in {
|
@keyframes fade-in {
|
||||||
animation: slide-in 0.25s ease-out;
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in {
|
||||||
|
animation: slide-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,21 +2,59 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="mb-4 sm:mb-6">
|
<div class="mb-4 sm:mb-6">
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4">
|
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4">
|
||||||
|
<div>
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Artículos Notificados</h1>
|
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Artículos Notificados</h1>
|
||||||
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:space-x-4">
|
<p v-if="currentUser" class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span v-if="isAdmin">Todos los artículos</span>
|
||||||
|
<span v-else>Tus artículos</span>
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-300 ml-1">({{ currentUser }})</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:space-x-4 flex-wrap">
|
||||||
<select
|
<select
|
||||||
v-model="selectedPlatform"
|
v-model="selectedPlatform"
|
||||||
@change="loadArticles"
|
@change="loadArticles"
|
||||||
class="input text-sm sm:text-base"
|
class="input text-sm sm:text-base"
|
||||||
style="width: 100%; min-width: 180px;"
|
style="width: 100%; min-width: 150px;"
|
||||||
>
|
>
|
||||||
<option value="">Todas las plataformas</option>
|
<option value="">Todas las plataformas</option>
|
||||||
<option value="wallapop">Wallapop</option>
|
<option value="wallapop">Wallapop</option>
|
||||||
<option value="vinted">Vinted</option>
|
<option value="vinted">Vinted</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select
|
||||||
|
v-if="isAdmin"
|
||||||
|
v-model="selectedUsername"
|
||||||
|
@change="loadArticles"
|
||||||
|
class="input text-sm sm:text-base"
|
||||||
|
style="width: 100%; min-width: 150px;"
|
||||||
|
>
|
||||||
|
<option value="">Todos los usuarios</option>
|
||||||
|
<option v-for="username in availableUsernames" :key="username" :value="username">
|
||||||
|
{{ username }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
v-model="selectedWorker"
|
||||||
|
@change="loadArticles"
|
||||||
|
class="input text-sm sm:text-base"
|
||||||
|
style="width: 100%; min-width: 150px;"
|
||||||
|
>
|
||||||
|
<option value="">Todos los workers</option>
|
||||||
|
<option v-for="worker in availableWorkers" :key="worker" :value="worker">
|
||||||
|
{{ worker }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
<button @click="loadArticles" class="btn btn-primary whitespace-nowrap">
|
<button @click="loadArticles" class="btn btn-primary whitespace-nowrap">
|
||||||
Actualizar
|
Actualizar
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="isAdmin"
|
||||||
|
@click="handleClearAllArticles"
|
||||||
|
class="btn btn-danger whitespace-nowrap"
|
||||||
|
title="Borrar todos los artículos (solo admin)"
|
||||||
|
>
|
||||||
|
🗑️ Borrar Todos
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -25,7 +63,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar artículos en Redis por título, descripción, precio, localidad..."
|
placeholder="Buscar artículos por título, descripción, precio, localidad..."
|
||||||
class="input pr-10"
|
class="input pr-10"
|
||||||
@input="searchQuery = $event.target.value"
|
@input="searchQuery = $event.target.value"
|
||||||
/>
|
/>
|
||||||
@@ -49,7 +87,7 @@
|
|||||||
|
|
||||||
<div v-else-if="searching" class="card text-center py-12">
|
<div v-else-if="searching" class="card text-center py-12">
|
||||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Buscando artículos en Redis...</p>
|
<p class="mt-2 text-gray-600 dark:text-gray-400">Buscando artículos...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="filteredArticles.length === 0 && searchQuery && !searching" class="card text-center py-12">
|
<div v-else-if="filteredArticles.length === 0 && searchQuery && !searching" class="card text-center py-12">
|
||||||
@@ -58,95 +96,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<div
|
<ArticleCard
|
||||||
v-for="article in filteredArticles"
|
v-for="article in filteredArticles"
|
||||||
:key="`${article.platform}-${article.id}`"
|
:key="`${article.platform}-${article.id}`"
|
||||||
class="card hover:shadow-lg transition-shadow"
|
:article="article"
|
||||||
>
|
|
||||||
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
|
||||||
<!-- Imagen del artículo -->
|
|
||||||
<div class="flex-shrink-0 self-center sm:self-start">
|
|
||||||
<div v-if="article.images && article.images.length > 0" class="w-24 h-24 sm:w-32 sm:h-32 relative">
|
|
||||||
<img
|
|
||||||
:src="article.images[0]"
|
|
||||||
:alt="article.title || 'Sin título'"
|
|
||||||
class="w-24 h-24 sm:w-32 sm:h-32 object-cover rounded-lg"
|
|
||||||
@error="($event) => handleImageError($event)"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div v-else class="w-24 h-24 sm:w-32 sm:h-32 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
|
||||||
<span class="text-gray-400 dark:text-gray-500 text-xs">Sin imagen</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Información del artículo -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-start justify-between mb-2">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
|
||||||
<span
|
|
||||||
class="px-2 py-1 text-xs font-semibold rounded flex-shrink-0"
|
|
||||||
:class="
|
|
||||||
article.platform === 'wallapop'
|
|
||||||
? 'bg-blue-100 text-blue-800'
|
|
||||||
: 'bg-green-100 text-green-800'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ article.platform?.toUpperCase() || 'N/A' }}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{{ formatDate(article.notifiedAt) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1 line-clamp-2" :title="article.title">
|
|
||||||
{{ article.title || 'Sin título' }}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div v-if="article.price !== null && article.price !== undefined" class="mb-2">
|
|
||||||
<span class="text-xl font-bold text-primary-600">
|
|
||||||
{{ article.price }} {{ article.currency || '€' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1 text-xs sm:text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
<div v-if="article.location" class="flex flex-wrap items-center">
|
|
||||||
<span class="font-medium">📍 Localidad:</span>
|
|
||||||
<span class="ml-2">{{ article.location }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="article.allows_shipping !== null" class="flex flex-wrap items-center">
|
|
||||||
<span class="font-medium">🚚 Envío:</span>
|
|
||||||
<span class="ml-2">{{ article.allows_shipping ? '✅ Acepta envíos' : '❌ No acepta envíos' }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="article.modified_at" class="flex flex-wrap items-center">
|
|
||||||
<span class="font-medium">🕒 Modificado:</span>
|
|
||||||
<span class="ml-2 break-all">{{ article.modified_at }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="article.description" class="text-xs sm:text-sm text-gray-700 dark:text-gray-300 mb-2 overflow-hidden line-clamp-2">
|
|
||||||
{{ article.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2 sm:gap-4 mt-3">
|
|
||||||
<a
|
|
||||||
v-if="article.url"
|
|
||||||
:href="article.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-primary-600 hover:text-primary-700 text-xs sm:text-sm font-medium break-all"
|
|
||||||
>
|
|
||||||
🔗 Ver anuncio
|
|
||||||
</a>
|
|
||||||
<span class="text-xs text-gray-400 dark:text-gray-500 break-all">
|
|
||||||
ID: {{ article.id }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!searchQuery" class="flex justify-center space-x-2 mt-6">
|
<div v-if="!searchQuery" class="flex justify-center space-x-2 mt-6">
|
||||||
<button
|
<button
|
||||||
@@ -161,7 +115,7 @@
|
|||||||
|
|
||||||
<p class="text-center text-xs sm:text-sm text-gray-500 mt-4 px-2">
|
<p class="text-center text-xs sm:text-sm text-gray-500 mt-4 px-2">
|
||||||
<span v-if="searchQuery">
|
<span v-if="searchQuery">
|
||||||
Mostrando {{ filteredArticles.length }} resultados de búsqueda en Redis
|
Mostrando {{ filteredArticles.length }} resultados de búsqueda
|
||||||
<span class="block sm:inline sm:ml-2 text-xs text-primary-600">(de {{ total }} artículos totales)</span>
|
<span class="block sm:inline sm:ml-2 text-xs text-primary-600">(de {{ total }} artículos totales)</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
@@ -176,6 +130,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
import authService from '../services/auth';
|
||||||
|
import ArticleCard from '../components/ArticleCard.vue';
|
||||||
|
|
||||||
|
const currentUser = ref(authService.getUsername() || null);
|
||||||
|
const isAdmin = ref(false);
|
||||||
|
|
||||||
const allArticles = ref([]);
|
const allArticles = ref([]);
|
||||||
const searchResults = ref([]);
|
const searchResults = ref([]);
|
||||||
@@ -185,12 +144,45 @@ const total = ref(0);
|
|||||||
const offset = ref(0);
|
const offset = ref(0);
|
||||||
const limit = 50;
|
const limit = 50;
|
||||||
const selectedPlatform = ref('');
|
const selectedPlatform = ref('');
|
||||||
|
const selectedUsername = ref('');
|
||||||
|
const selectedWorker = ref('');
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const autoPollInterval = ref(null);
|
const autoPollInterval = ref(null);
|
||||||
const searchTimeout = ref(null);
|
const searchTimeout = ref(null);
|
||||||
const POLL_INTERVAL = 30000; // 30 segundos
|
const POLL_INTERVAL = 30000; // 30 segundos
|
||||||
const SEARCH_DEBOUNCE = 500; // 500ms de debounce para búsqueda
|
const SEARCH_DEBOUNCE = 500; // 500ms de debounce para búsqueda
|
||||||
|
|
||||||
|
// Obtener listas de usuarios y workers únicos de los artículos
|
||||||
|
const availableUsernames = computed(() => {
|
||||||
|
const usernames = new Set();
|
||||||
|
allArticles.value.forEach(article => {
|
||||||
|
if (article.username) {
|
||||||
|
usernames.add(article.username);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
searchResults.value.forEach(article => {
|
||||||
|
if (article.username) {
|
||||||
|
usernames.add(article.username);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(usernames).sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableWorkers = computed(() => {
|
||||||
|
const workers = new Set();
|
||||||
|
allArticles.value.forEach(article => {
|
||||||
|
if (article.worker_name) {
|
||||||
|
workers.add(article.worker_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
searchResults.value.forEach(article => {
|
||||||
|
if (article.worker_name) {
|
||||||
|
workers.add(article.worker_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(workers).sort();
|
||||||
|
});
|
||||||
|
|
||||||
// Artículos que se muestran (búsqueda o lista normal)
|
// Artículos que se muestran (búsqueda o lista normal)
|
||||||
const filteredArticles = computed(() => {
|
const filteredArticles = computed(() => {
|
||||||
if (searchQuery.value.trim()) {
|
if (searchQuery.value.trim()) {
|
||||||
@@ -199,9 +191,14 @@ const filteredArticles = computed(() => {
|
|||||||
return allArticles.value;
|
return allArticles.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDate(timestamp) {
|
|
||||||
if (!timestamp) return 'N/A';
|
function checkUserRole() {
|
||||||
return new Date(timestamp).toLocaleString('es-ES');
|
currentUser.value = authService.getUsername() || null;
|
||||||
|
isAdmin.value = currentUser.value === 'admin'; // Simplificación temporal
|
||||||
|
// Si no es admin, no permitir filtrar por username
|
||||||
|
if (!isAdmin.value && selectedUsername.value) {
|
||||||
|
selectedUsername.value = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadArticles(reset = true, silent = false) {
|
async function loadArticles(reset = true, silent = false) {
|
||||||
@@ -215,9 +212,19 @@ async function loadArticles(reset = true, silent = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.getArticles(limit, offset.value);
|
// Construir query params para filtros
|
||||||
|
const params = { limit, offset: offset.value };
|
||||||
|
if (selectedPlatform.value) params.platform = selectedPlatform.value;
|
||||||
|
// Solo permitir filtrar por username si es admin
|
||||||
|
if (selectedUsername.value && isAdmin.value) {
|
||||||
|
params.username = selectedUsername.value;
|
||||||
|
}
|
||||||
|
if (selectedWorker.value) params.worker_name = selectedWorker.value;
|
||||||
|
|
||||||
|
const data = await api.getArticles(limit, offset.value, params);
|
||||||
|
|
||||||
let filtered = data.articles;
|
let filtered = data.articles;
|
||||||
|
// El filtro de plataforma se aplica en el backend ahora, pero mantenemos compatibilidad
|
||||||
if (selectedPlatform.value) {
|
if (selectedPlatform.value) {
|
||||||
filtered = filtered.filter(a => a.platform === selectedPlatform.value);
|
filtered = filtered.filter(a => a.platform === selectedPlatform.value);
|
||||||
}
|
}
|
||||||
@@ -240,17 +247,82 @@ async function loadArticles(reset = true, silent = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAuthChange() {
|
||||||
|
checkUserRole();
|
||||||
|
if (currentUser.value) {
|
||||||
|
loadArticles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
loadArticles(false);
|
loadArticles(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWSMessage(event) {
|
function handleWSMessage(event) {
|
||||||
const data = event.detail;
|
const data = event.detail;
|
||||||
if (data.type === 'articles_updated') {
|
if (data.type === 'articles_updated' || data.type === 'articles_cleared' || data.type === 'cache_cleared') {
|
||||||
loadArticles();
|
loadArticles();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleClearAllArticles() {
|
||||||
|
if (!isAdmin.value) {
|
||||||
|
alert('Solo los administradores pueden borrar todos los artículos');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = confirm(
|
||||||
|
'⚠️ ¿Estás seguro de que quieres borrar TODOS los artículos?\n\n' +
|
||||||
|
'Esta acción eliminará permanentemente todos los artículos de la base de datos.\n' +
|
||||||
|
'Esta acción NO se puede deshacer.\n\n' +
|
||||||
|
'¿Continuar?'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmación adicional
|
||||||
|
const doubleConfirmed = confirm(
|
||||||
|
'⚠️ ÚLTIMA CONFIRMACIÓN ⚠️\n\n' +
|
||||||
|
'Estás a punto de borrar TODOS los artículos de la base de datos.\n' +
|
||||||
|
'Esta acción es IRREVERSIBLE.\n\n' +
|
||||||
|
'¿Estás absolutamente seguro?'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!doubleConfirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.clearAllArticles();
|
||||||
|
alert(`✅ ${result.message || `Se borraron ${result.count || 0} artículos`}`);
|
||||||
|
|
||||||
|
// Limpiar la vista
|
||||||
|
allArticles.value = [];
|
||||||
|
searchResults.value = [];
|
||||||
|
total.value = 0;
|
||||||
|
offset.value = 0;
|
||||||
|
|
||||||
|
// Recargar artículos (ahora estará vacío)
|
||||||
|
await loadArticles();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error borrando artículos:', error);
|
||||||
|
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
alert('❌ Error: No tienes permisos de administrador para realizar esta acción');
|
||||||
|
} else if (error.response?.status === 401) {
|
||||||
|
alert('❌ Error: Debes estar autenticado para realizar esta acción');
|
||||||
|
} else {
|
||||||
|
alert('❌ Error al borrar artículos: ' + (error.response?.data?.error || error.message || 'Error desconocido'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function searchArticles(query) {
|
async function searchArticles(query) {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
@@ -265,10 +337,16 @@ async function searchArticles(query) {
|
|||||||
|
|
||||||
let filtered = data.articles || [];
|
let filtered = data.articles || [];
|
||||||
|
|
||||||
// Aplicar filtro de plataforma si está seleccionado
|
// Aplicar filtros si están seleccionados
|
||||||
if (selectedPlatform.value) {
|
if (selectedPlatform.value) {
|
||||||
filtered = filtered.filter(a => a.platform === selectedPlatform.value);
|
filtered = filtered.filter(a => a.platform === selectedPlatform.value);
|
||||||
}
|
}
|
||||||
|
if (selectedUsername.value) {
|
||||||
|
filtered = filtered.filter(a => a.username === selectedUsername.value);
|
||||||
|
}
|
||||||
|
if (selectedWorker.value) {
|
||||||
|
filtered = filtered.filter(a => a.worker_name === selectedWorker.value);
|
||||||
|
}
|
||||||
|
|
||||||
searchResults.value = filtered;
|
searchResults.value = filtered;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -298,15 +376,13 @@ watch(searchQuery, (newQuery) => {
|
|||||||
}, SEARCH_DEBOUNCE);
|
}, SEARCH_DEBOUNCE);
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleImageError(event) {
|
|
||||||
// Si la imagen falla al cargar, reemplazar con placeholder
|
|
||||||
event.target.onerror = null; // Prevenir bucle infinito
|
|
||||||
event.target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgdmlld0JveD0iMCAwIDEyOCAxMjgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik00OCA0OEg4ME04MCA4MEg0OE00OCA0OEw2NCA2NEw4MCA0OE00OCA4MEw2NCA2NE04MCA4MEw2NCA2NEw0OCA4MCIgc3Ryb2tlPSIjOUI5Q0E0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K';
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
checkUserRole();
|
||||||
loadArticles();
|
loadArticles();
|
||||||
window.addEventListener('ws-message', handleWSMessage);
|
window.addEventListener('ws-message', handleWSMessage);
|
||||||
|
window.addEventListener('auth-logout', handleAuthChange);
|
||||||
|
window.addEventListener('auth-login', handleAuthChange);
|
||||||
|
|
||||||
// Iniciar autopoll para actualizar automáticamente
|
// Iniciar autopoll para actualizar automáticamente
|
||||||
autoPollInterval.value = setInterval(() => {
|
autoPollInterval.value = setInterval(() => {
|
||||||
@@ -316,6 +392,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('ws-message', handleWSMessage);
|
window.removeEventListener('ws-message', handleWSMessage);
|
||||||
|
window.removeEventListener('auth-logout', handleAuthChange);
|
||||||
|
window.removeEventListener('auth-login', handleAuthChange);
|
||||||
|
|
||||||
// Limpiar el intervalo cuando el componente se desmonte
|
// Limpiar el intervalo cuando el componente se desmonte
|
||||||
if (autoPollInterval.value) {
|
if (autoPollInterval.value) {
|
||||||
|
|||||||
@@ -1,87 +1,161 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 mb-4 sm:mb-6">Dashboard</h1>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Dashboard</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<!-- Estadísticas -->
|
<span v-if="isAdmin">Resumen general del sistema (estadísticas de todos los usuarios)</span>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 mb-6 sm:mb-8">
|
<span v-else>Tu resumen personal</span>
|
||||||
<div class="card">
|
<span v-if="currentUser" class="font-medium text-gray-700 dark:text-gray-300 ml-1">({{ currentUser }})</span>
|
||||||
<div class="flex items-center">
|
</p>
|
||||||
<div class="flex-shrink-0 bg-primary-100 rounded-lg p-3">
|
|
||||||
<Cog6ToothIcon class="w-6 h-6 text-primary-600" />
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 sm:ml-4">
|
|
||||||
<p class="text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400">Workers Activos</p>
|
|
||||||
<p class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{{ stats.activeWorkers }}/{{ stats.totalWorkers }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<!-- Statistics Cards -->
|
||||||
<div class="flex items-center">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<div class="flex-shrink-0 bg-green-100 rounded-lg p-3">
|
<!-- Workers Card -->
|
||||||
<HeartIcon class="w-6 h-6 text-green-600" />
|
<div class="stat-card">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<Cog6ToothIcon class="w-7 h-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Favoritos</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Workers Activos</p>
|
||||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ stats.totalFavorites }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
</div>
|
{{ stats.activeWorkers }}<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ stats.totalWorkers }}</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0 bg-blue-100 rounded-lg p-3">
|
|
||||||
<DocumentTextIcon class="w-6 h-6 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Artículos Notificados</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ stats.totalNotified }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0 bg-purple-100 rounded-lg p-3">
|
|
||||||
<ChartBarIcon class="w-6 h-6 text-purple-600" />
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Plataformas</p>
|
|
||||||
<p class="text-sm font-bold text-gray-900 dark:text-gray-100">
|
|
||||||
W: {{ stats.platforms?.wallapop || 0 }} | V: {{ stats.platforms?.vinted || 0 }}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">Estado del sistema</span>
|
||||||
|
<span :class="stats.activeWorkers > 0 ? 'badge badge-success' : 'badge badge-danger'">
|
||||||
|
{{ stats.activeWorkers > 0 ? 'Activo' : 'Inactivo' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gráfico de plataformas -->
|
<!-- Favorites Card -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
<div class="stat-card">
|
||||||
<div class="card">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Distribución por Plataforma</h2>
|
<div class="flex items-center space-x-4">
|
||||||
<div class="space-y-4">
|
<div class="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
<div>
|
<HeartIcon class="w-7 h-7 text-white" />
|
||||||
<div class="flex justify-between mb-2">
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Wallapop</span>
|
|
||||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ stats.platforms?.wallapop || 0 }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Favoritos</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ stats.totalFavorites }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<router-link to="/favorites" class="text-xs text-primary-600 dark:text-primary-400 hover:underline font-medium flex items-center">
|
||||||
|
Ver todos →
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Articles Card -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<DocumentTextIcon class="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Artículos Notificados</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ stats.totalNotified }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<router-link to="/articles" class="text-xs text-primary-600 dark:text-primary-400 hover:underline font-medium flex items-center">
|
||||||
|
Ver todos →
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Platforms Card -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<ChartBarIcon class="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Plataformas</p>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
<span class="text-blue-600 dark:text-blue-400">W:</span> {{ stats.platforms?.wallapop || 0 }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
<span class="text-green-600 dark:text-green-400">V:</span> {{ stats.platforms?.vinted || 0 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">Total de plataformas activas</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts and Quick Actions -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Platform Distribution -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Distribución por Plataforma</h3>
|
||||||
|
<p class="card-subtitle">Artículos notificados por plataforma</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Wallapop -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="w-3 h-3 bg-primary-600 rounded-full"></span>
|
||||||
|
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">Wallapop</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-lg font-bold text-gray-900 dark:text-gray-100">{{ stats.platforms?.wallapop || 0 }}</span>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
({{ getPercentage(stats.platforms?.wallapop || 0, stats.totalNotified) }}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="bg-primary-600 h-2 rounded-full"
|
class="bg-gradient-to-r from-primary-500 to-primary-600 h-3 rounded-full transition-all duration-500 shadow-sm"
|
||||||
:style="{
|
:style="{
|
||||||
width: `${getPercentage(stats.platforms?.wallapop || 0, stats.totalNotified)}%`,
|
width: `${getPercentage(stats.platforms?.wallapop || 0, stats.totalNotified)}%`,
|
||||||
}"
|
}"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Vinted -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex justify-between mb-2">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Vinted</span>
|
<div class="flex items-center space-x-2">
|
||||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ stats.platforms?.vinted || 0 }}</span>
|
<span class="w-3 h-3 bg-green-600 rounded-full"></span>
|
||||||
|
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">Vinted</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-lg font-bold text-gray-900 dark:text-gray-100">{{ stats.platforms?.vinted || 0 }}</span>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
({{ getPercentage(stats.platforms?.vinted || 0, stats.totalNotified) }}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="bg-green-600 h-2 rounded-full"
|
class="bg-gradient-to-r from-green-500 to-green-600 h-3 rounded-full transition-all duration-500 shadow-sm"
|
||||||
:style="{
|
:style="{
|
||||||
width: `${getPercentage(stats.platforms?.vinted || 0, stats.totalNotified)}%`,
|
width: `${getPercentage(stats.platforms?.vinted || 0, stats.totalNotified)}%`,
|
||||||
}"
|
}"
|
||||||
@@ -91,29 +165,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Accesos Rápidos</h2>
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Accesos Rápidos</h3>
|
||||||
|
<p class="card-subtitle">Navegación rápida a secciones principales</p>
|
||||||
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<router-link
|
<router-link
|
||||||
to="/articles"
|
to="/articles"
|
||||||
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
class="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-xl hover:from-gray-100 hover:to-gray-200 dark:hover:from-gray-600 dark:hover:to-gray-700 transition-all duration-200 group border border-gray-200 dark:border-gray-700"
|
||||||
>
|
>
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Ver todos los artículos</span>
|
<div class="flex items-center space-x-3">
|
||||||
<ArrowRightIcon class="w-5 h-5 text-gray-400 dark:text-gray-500" />
|
<div class="w-10 h-10 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center group-hover:bg-blue-200 dark:group-hover:bg-blue-900/70 transition-colors">
|
||||||
|
<DocumentTextIcon class="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Artículos</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Ver todos los artículos notificados</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRightIcon class="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors" />
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
to="/favorites"
|
to="/favorites"
|
||||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
class="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-xl hover:from-gray-100 hover:to-gray-200 dark:hover:from-gray-600 dark:hover:to-gray-700 transition-all duration-200 group border border-gray-200 dark:border-gray-700"
|
||||||
>
|
>
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Ver favoritos</span>
|
<div class="flex items-center space-x-3">
|
||||||
<ArrowRightIcon class="w-5 h-5 text-gray-400 dark:text-gray-500" />
|
<div class="w-10 h-10 bg-green-100 dark:bg-green-900/50 rounded-lg flex items-center justify-center group-hover:bg-green-200 dark:group-hover:bg-green-900/70 transition-colors">
|
||||||
|
<HeartIcon class="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Favoritos</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Gestionar artículos favoritos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRightIcon class="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors" />
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
to="/workers"
|
to="/workers"
|
||||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
class="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-xl hover:from-gray-100 hover:to-gray-200 dark:hover:from-gray-600 dark:hover:to-gray-700 transition-all duration-200 group border border-gray-200 dark:border-gray-700"
|
||||||
>
|
>
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Gestionar workers</span>
|
<div class="flex items-center space-x-3">
|
||||||
<ArrowRightIcon class="w-5 h-5 text-gray-400 dark:text-gray-500" />
|
<div class="w-10 h-10 bg-primary-100 dark:bg-primary-900/50 rounded-lg flex items-center justify-center group-hover:bg-primary-200 dark:group-hover:bg-primary-900/70 transition-colors">
|
||||||
|
<Cog6ToothIcon class="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Workers</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Configurar y gestionar workers</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRightIcon class="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,6 +228,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue';
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
import authService from '../services/auth';
|
||||||
import {
|
import {
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
HeartIcon,
|
HeartIcon,
|
||||||
@@ -140,6 +245,9 @@ const stats = ref({
|
|||||||
platforms: {},
|
platforms: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentUser = ref(authService.getUsername() || null);
|
||||||
|
const isAdmin = ref(false);
|
||||||
|
|
||||||
function getPercentage(value, total) {
|
function getPercentage(value, total) {
|
||||||
if (!total || total === 0) return 0;
|
if (!total || total === 0) return 0;
|
||||||
return Math.round((value / total) * 100);
|
return Math.round((value / total) * 100);
|
||||||
@@ -148,11 +256,28 @@ function getPercentage(value, total) {
|
|||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
stats.value = await api.getStats();
|
stats.value = await api.getStats();
|
||||||
|
// Verificar si el usuario es admin (se puede inferir de si ve todas las estadísticas)
|
||||||
|
// O podemos añadir un endpoint para verificar el rol
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando estadísticas:', error);
|
console.error('Error cargando estadísticas:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkUserRole() {
|
||||||
|
currentUser.value = authService.getUsername() || null;
|
||||||
|
// Por ahora, asumimos que si no hay usuario o el usuario no es admin, no es admin
|
||||||
|
// En el futuro, se podría añadir un endpoint para verificar el rol
|
||||||
|
// Por defecto, asumimos que el usuario normal no es admin
|
||||||
|
isAdmin.value = currentUser.value === 'admin'; // Simplificación temporal
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthChange() {
|
||||||
|
checkUserRole();
|
||||||
|
if (currentUser.value) {
|
||||||
|
loadStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleWSMessage(event) {
|
function handleWSMessage(event) {
|
||||||
const data = event.detail;
|
const data = event.detail;
|
||||||
if (data.type === 'workers_updated' || data.type === 'favorites_updated') {
|
if (data.type === 'workers_updated' || data.type === 'favorites_updated') {
|
||||||
@@ -163,8 +288,11 @@ function handleWSMessage(event) {
|
|||||||
let interval = null;
|
let interval = null;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
checkUserRole();
|
||||||
loadStats();
|
loadStats();
|
||||||
window.addEventListener('ws-message', handleWSMessage);
|
window.addEventListener('ws-message', handleWSMessage);
|
||||||
|
window.addEventListener('auth-logout', handleAuthChange);
|
||||||
|
window.addEventListener('auth-login', handleAuthChange);
|
||||||
interval = setInterval(loadStats, 10000); // Actualizar cada 10 segundos
|
interval = setInterval(loadStats, 10000); // Actualizar cada 10 segundos
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,6 +301,8 @@ onUnmounted(() => {
|
|||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
}
|
}
|
||||||
window.removeEventListener('ws-message', handleWSMessage);
|
window.removeEventListener('ws-message', handleWSMessage);
|
||||||
|
window.removeEventListener('auth-logout', handleAuthChange);
|
||||||
|
window.removeEventListener('auth-login', handleAuthChange);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||||
|
<div>
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Favoritos</h1>
|
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Favoritos</h1>
|
||||||
|
<p v-if="currentUser" class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Tus favoritos
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-300 ml-1">({{ currentUser }})</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<button @click="loadFavorites" class="btn btn-primary self-start sm:self-auto">
|
<button @click="loadFavorites" class="btn btn-primary self-start sm:self-auto">
|
||||||
Actualizar
|
Actualizar
|
||||||
</button>
|
</button>
|
||||||
@@ -12,6 +18,14 @@
|
|||||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando favoritos...</p>
|
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando favoritos...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!currentUser" class="card text-center py-12">
|
||||||
|
<HeartIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-lg">Inicia sesión para ver tus favoritos</p>
|
||||||
|
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
|
||||||
|
Necesitas estar autenticado para ver y gestionar tus favoritos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="favorites.length === 0" class="card text-center py-12">
|
<div v-else-if="favorites.length === 0" class="card text-center py-12">
|
||||||
<HeartIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
<HeartIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-lg">No tienes favoritos aún</p>
|
<p class="text-gray-600 dark:text-gray-400 text-lg">No tienes favoritos aún</p>
|
||||||
@@ -20,99 +34,49 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div v-else class="space-y-4">
|
||||||
<div
|
<ArticleCard
|
||||||
v-for="favorite in favorites"
|
v-for="favorite in favorites"
|
||||||
:key="`${favorite.platform}-${favorite.id}`"
|
:key="`${favorite.platform}-${favorite.id}`"
|
||||||
class="card hover:shadow-lg transition-shadow"
|
:article="favorite"
|
||||||
>
|
:show-remove-button="true"
|
||||||
<div class="flex items-start justify-between mb-4">
|
@remove="removeFavorite"
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center space-x-2 mb-2">
|
|
||||||
<span
|
|
||||||
class="px-2 py-1 text-xs font-semibold rounded"
|
|
||||||
:class="
|
|
||||||
favorite.platform === 'wallapop'
|
|
||||||
? 'bg-blue-100 text-blue-800'
|
|
||||||
: 'bg-green-100 text-green-800'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ favorite.platform?.toUpperCase() || 'N/A' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
|
||||||
{{ favorite.title || 'Sin título' }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
{{ favorite.description?.substring(0, 100) }}...
|
|
||||||
</p>
|
|
||||||
<div class="flex items-center justify-between mt-4">
|
|
||||||
<span class="text-xl font-bold text-primary-600">
|
|
||||||
{{ favorite.price }} {{ favorite.currency }}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{{ favorite.location }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row gap-2 sm:space-x-2 mt-4">
|
|
||||||
<a
|
|
||||||
:href="favorite.url"
|
|
||||||
target="_blank"
|
|
||||||
class="flex-1 btn btn-primary text-center text-sm sm:text-base"
|
|
||||||
>
|
|
||||||
Ver artículo
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
@click="removeFavorite(favorite.platform, favorite.id)"
|
|
||||||
class="btn btn-danger text-sm sm:text-base"
|
|
||||||
>
|
|
||||||
Eliminar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="favorite.images && favorite.images.length > 0" class="mt-4">
|
|
||||||
<img
|
|
||||||
:src="favorite.images[0]"
|
|
||||||
:alt="favorite.title"
|
|
||||||
class="w-full h-48 object-cover rounded-lg"
|
|
||||||
@error="handleImageError"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2">
|
|
||||||
Añadido: {{ formatDate(favorite.addedAt) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue';
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
import authService from '../services/auth';
|
||||||
import { HeartIcon } from '@heroicons/vue/24/outline';
|
import { HeartIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import ArticleCard from '../components/ArticleCard.vue';
|
||||||
|
|
||||||
const favorites = ref([]);
|
const favorites = ref([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const currentUser = ref(authService.getUsername() || null);
|
||||||
|
const isAdmin = ref(false);
|
||||||
|
|
||||||
function formatDate(dateString) {
|
|
||||||
if (!dateString) return 'N/A';
|
|
||||||
return new Date(dateString).toLocaleString('es-ES');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleImageError(event) {
|
|
||||||
event.target.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFavorites() {
|
async function loadFavorites() {
|
||||||
|
// Solo cargar si hay usuario autenticado
|
||||||
|
if (!currentUser.value) {
|
||||||
|
favorites.value = [];
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
favorites.value = await api.getFavorites();
|
favorites.value = await api.getFavorites();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando favoritos:', error);
|
console.error('Error cargando favoritos:', error);
|
||||||
|
// Si hay error de autenticación, limpiar favoritos
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
favorites.value = [];
|
||||||
|
currentUser.value = null;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -132,20 +96,40 @@ async function removeFavorite(platform, id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkUserRole() {
|
||||||
|
currentUser.value = authService.getUsername() || null;
|
||||||
|
isAdmin.value = currentUser.value === 'admin'; // Simplificación temporal
|
||||||
|
}
|
||||||
|
|
||||||
function handleWSMessage(event) {
|
function handleWSMessage(event) {
|
||||||
const data = event.detail;
|
const data = event.detail;
|
||||||
if (data.type === 'favorites_updated') {
|
if (data.type === 'favorites_updated') {
|
||||||
|
// Solo actualizar si es para el usuario actual
|
||||||
|
if (data.username === currentUser.value) {
|
||||||
favorites.value = data.data;
|
favorites.value = data.data;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthChange() {
|
||||||
|
checkUserRole();
|
||||||
|
if (currentUser.value) {
|
||||||
|
loadFavorites();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
checkUserRole();
|
||||||
loadFavorites();
|
loadFavorites();
|
||||||
window.addEventListener('ws-message', handleWSMessage);
|
window.addEventListener('ws-message', handleWSMessage);
|
||||||
|
window.addEventListener('auth-logout', handleAuthChange);
|
||||||
|
window.addEventListener('auth-login', handleAuthChange);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('ws-message', handleWSMessage);
|
window.removeEventListener('ws-message', handleWSMessage);
|
||||||
|
window.removeEventListener('auth-logout', handleAuthChange);
|
||||||
|
window.removeEventListener('auth-login', handleAuthChange);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-2 sm:p-6">
|
<div v-if="accessDenied || (!isAdmin && currentUser)" class="card text-center py-12">
|
||||||
|
<DocumentMagnifyingGlassIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-lg font-semibold">Acceso Denegado</p>
|
||||||
|
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
|
||||||
|
Solo los administradores pueden ver los logs del sistema
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="card p-2 sm:p-6">
|
||||||
<div
|
<div
|
||||||
ref="logsContainer"
|
ref="logsContainer"
|
||||||
class="bg-gray-900 text-green-400 font-mono text-xs sm:text-sm p-3 sm:p-4 rounded-lg overflow-x-auto max-h-[400px] sm:max-h-[600px] overflow-y-auto"
|
class="bg-gray-900 text-green-400 font-mono text-xs sm:text-sm p-3 sm:p-4 rounded-lg overflow-x-auto max-h-[400px] sm:max-h-[600px] overflow-y-auto"
|
||||||
@@ -87,6 +95,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
import authService from '../services/auth';
|
||||||
|
import { DocumentMagnifyingGlassIcon } from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
const logs = ref([]);
|
const logs = ref([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@@ -96,6 +106,9 @@ const refreshIntervalSeconds = ref(5);
|
|||||||
const followLatestLog = ref(true);
|
const followLatestLog = ref(true);
|
||||||
const logsContainer = ref(null);
|
const logsContainer = ref(null);
|
||||||
const lastLineNumber = ref(-1); // Número de la última línea leída
|
const lastLineNumber = ref(-1); // Número de la última línea leída
|
||||||
|
const currentUser = ref(authService.getUsername() || null);
|
||||||
|
const isAdmin = ref(false);
|
||||||
|
const accessDenied = ref(false);
|
||||||
let refreshInterval = null;
|
let refreshInterval = null;
|
||||||
|
|
||||||
const filteredLogs = computed(() => {
|
const filteredLogs = computed(() => {
|
||||||
@@ -113,7 +126,21 @@ function getLogColor(log) {
|
|||||||
return 'text-green-400';
|
return 'text-green-400';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkUserRole() {
|
||||||
|
currentUser.value = authService.getUsername() || null;
|
||||||
|
isAdmin.value = currentUser.value === 'admin'; // Simplificación temporal
|
||||||
|
}
|
||||||
|
|
||||||
async function loadLogs(forceReload = false, shouldScroll = null) {
|
async function loadLogs(forceReload = false, shouldScroll = null) {
|
||||||
|
// Verificar que el usuario es admin antes de cargar logs
|
||||||
|
if (!isAdmin.value) {
|
||||||
|
accessDenied.value = true;
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
accessDenied.value = false;
|
||||||
|
|
||||||
// Si shouldScroll es null, usar la configuración de followLatestLog
|
// Si shouldScroll es null, usar la configuración de followLatestLog
|
||||||
const shouldAutoScroll = shouldScroll !== null ? shouldScroll : followLatestLog.value;
|
const shouldAutoScroll = shouldScroll !== null ? shouldScroll : followLatestLog.value;
|
||||||
|
|
||||||
@@ -176,6 +203,10 @@ async function loadLogs(forceReload = false, shouldScroll = null) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando logs:', error);
|
console.error('Error cargando logs:', error);
|
||||||
|
// Si hay error 403, es porque no es admin
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
accessDenied.value = true;
|
||||||
|
}
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
} finally {
|
} finally {
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
@@ -214,11 +245,12 @@ function handleWSMessage(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
checkUserRole();
|
||||||
loadLogs(true, true); // Primera carga forzada siempre hace scroll
|
loadLogs(true, true); // Primera carga forzada siempre hace scroll
|
||||||
window.addEventListener('ws-message', handleWSMessage);
|
window.addEventListener('ws-message', handleWSMessage);
|
||||||
|
|
||||||
// Inicializar auto-refresh si está activado
|
// Inicializar auto-refresh si está activado
|
||||||
if (autoRefresh.value) {
|
if (autoRefresh.value && isAdmin.value) {
|
||||||
updateRefreshInterval();
|
updateRefreshInterval();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,11 @@
|
|||||||
>
|
>
|
||||||
🔑 Cambiar Mi Contraseña
|
🔑 Cambiar Mi Contraseña
|
||||||
</button>
|
</button>
|
||||||
<button @click="showAddModal = true" class="btn btn-primary text-xs sm:text-sm whitespace-nowrap">
|
<button
|
||||||
|
v-if="isAdmin"
|
||||||
|
@click="showAddModal = true"
|
||||||
|
class="btn btn-primary text-xs sm:text-sm whitespace-nowrap"
|
||||||
|
>
|
||||||
+ Crear Usuario
|
+ Crear Usuario
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,6 +43,12 @@
|
|||||||
>
|
>
|
||||||
Tú
|
Tú
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="user.role === 'admin'"
|
||||||
|
class="px-2 py-1 text-xs font-semibold rounded bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600 dark:text-gray-400">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
@@ -58,6 +68,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
v-if="user.username === currentUser"
|
||||||
|
@click="showTelegramModal = true"
|
||||||
|
class="btn btn-secondary text-xs sm:text-sm"
|
||||||
|
title="Configurar Telegram"
|
||||||
|
>
|
||||||
|
📱 Telegram
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="user.username === currentUser"
|
v-if="user.username === currentUser"
|
||||||
@click="showChangePasswordModal = true"
|
@click="showChangePasswordModal = true"
|
||||||
@@ -67,7 +85,7 @@
|
|||||||
🔑 Cambiar Contraseña
|
🔑 Cambiar Contraseña
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="user.username !== currentUser"
|
v-if="user.username !== currentUser && isAdmin"
|
||||||
@click="confirmDeleteUser(user.username)"
|
@click="confirmDeleteUser(user.username)"
|
||||||
class="btn btn-danger text-xs sm:text-sm"
|
class="btn btn-danger text-xs sm:text-sm"
|
||||||
title="Eliminar usuario"
|
title="Eliminar usuario"
|
||||||
@@ -275,6 +293,82 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal para configuración de Telegram -->
|
||||||
|
<div
|
||||||
|
v-if="showTelegramModal"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-2 sm:p-4"
|
||||||
|
@click.self="closeTelegramModal"
|
||||||
|
>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 sm:p-6 max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-between mb-3 sm:mb-4">
|
||||||
|
<h2 class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">Configuración de Telegram</h2>
|
||||||
|
<button
|
||||||
|
@click="closeTelegramModal"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
title="Cerrar"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Configura tu bot de Telegram y canal para recibir notificaciones de tus workers.
|
||||||
|
</p>
|
||||||
|
<form @submit.prevent="saveTelegramConfig" class="space-y-4">
|
||||||
|
<div v-if="telegramError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||||
|
{{ telegramError }}
|
||||||
|
</div>
|
||||||
|
<div v-if="telegramSuccess" class="bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded">
|
||||||
|
{{ telegramSuccess }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Token del Bot <span class="text-red-500">*</span></label>
|
||||||
|
<input
|
||||||
|
v-model="telegramForm.token"
|
||||||
|
type="password"
|
||||||
|
class="input"
|
||||||
|
placeholder="Ej: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Obtén tu token desde @BotFather en Telegram
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Canal o Grupo <span class="text-red-500">*</span></label>
|
||||||
|
<input
|
||||||
|
v-model="telegramForm.channel"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
placeholder="Ej: @micanal o -1001234567890"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Usa @nombrecanal para canales públicos o el ID numérico para grupos/canales privados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
v-model="telegramForm.enable_polling"
|
||||||
|
type="checkbox"
|
||||||
|
id="enable_polling"
|
||||||
|
class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<label for="enable_polling" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Habilitar polling del bot (para comandos /favs, /threads, etc.)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 sm:space-x-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button type="button" @click="closeTelegramModal" class="btn btn-secondary text-sm sm:text-base">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary text-sm sm:text-base" :disabled="loadingAction">
|
||||||
|
{{ loadingAction ? 'Guardando...' : 'Guardar' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Modal de confirmación para eliminar -->
|
<!-- Modal de confirmación para eliminar -->
|
||||||
<div
|
<div
|
||||||
v-if="userToDelete"
|
v-if="userToDelete"
|
||||||
@@ -323,7 +417,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import authService from '../services/auth';
|
import authService from '../services/auth';
|
||||||
|
|
||||||
@@ -332,10 +426,13 @@ const loading = ref(true);
|
|||||||
const loadingAction = ref(false);
|
const loadingAction = ref(false);
|
||||||
const showAddModal = ref(false);
|
const showAddModal = ref(false);
|
||||||
const showChangePasswordModal = ref(false);
|
const showChangePasswordModal = ref(false);
|
||||||
|
const showTelegramModal = ref(false);
|
||||||
const userToDelete = ref(null);
|
const userToDelete = ref(null);
|
||||||
const addError = ref('');
|
const addError = ref('');
|
||||||
const passwordError = ref('');
|
const passwordError = ref('');
|
||||||
const passwordSuccess = ref('');
|
const passwordSuccess = ref('');
|
||||||
|
const telegramError = ref('');
|
||||||
|
const telegramSuccess = ref('');
|
||||||
|
|
||||||
const userForm = ref({
|
const userForm = ref({
|
||||||
username: '',
|
username: '',
|
||||||
@@ -349,10 +446,19 @@ const passwordForm = ref({
|
|||||||
newPasswordConfirm: '',
|
newPasswordConfirm: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const telegramForm = ref({
|
||||||
|
token: '',
|
||||||
|
channel: '',
|
||||||
|
enable_polling: false
|
||||||
|
});
|
||||||
|
|
||||||
const isAuthenticated = computed(() => authService.hasCredentials());
|
const isAuthenticated = computed(() => authService.hasCredentials());
|
||||||
const currentUser = computed(() => {
|
const currentUser = computed(() => {
|
||||||
return authService.getUsername() || '';
|
return authService.getUsername() || '';
|
||||||
});
|
});
|
||||||
|
const isAdmin = computed(() => {
|
||||||
|
return authService.isAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
function formatDate(dateString) {
|
function formatDate(dateString) {
|
||||||
if (!dateString) return 'N/A';
|
if (!dateString) return 'N/A';
|
||||||
@@ -524,6 +630,61 @@ function closeChangePasswordModal() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeTelegramModal() {
|
||||||
|
showTelegramModal.value = false;
|
||||||
|
telegramError.value = '';
|
||||||
|
telegramSuccess.value = '';
|
||||||
|
telegramForm.value = {
|
||||||
|
token: '',
|
||||||
|
channel: '',
|
||||||
|
enable_polling: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTelegramConfig() {
|
||||||
|
try {
|
||||||
|
const config = await api.getTelegramConfig();
|
||||||
|
if (config) {
|
||||||
|
telegramForm.value = {
|
||||||
|
token: config.token || '',
|
||||||
|
channel: config.channel || '',
|
||||||
|
enable_polling: config.enable_polling || false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cargando configuración de Telegram:', error);
|
||||||
|
telegramError.value = 'Error cargando la configuración de Telegram';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTelegramConfig() {
|
||||||
|
telegramError.value = '';
|
||||||
|
telegramSuccess.value = '';
|
||||||
|
|
||||||
|
if (!telegramForm.value.token || !telegramForm.value.channel) {
|
||||||
|
telegramError.value = 'Token y canal son requeridos';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingAction.value = true;
|
||||||
|
try {
|
||||||
|
await api.setTelegramConfig(telegramForm.value);
|
||||||
|
telegramSuccess.value = 'Configuración de Telegram guardada correctamente';
|
||||||
|
setTimeout(() => {
|
||||||
|
closeTelegramModal();
|
||||||
|
}, 1500);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error guardando configuración de Telegram:', error);
|
||||||
|
if (error.response?.data?.error) {
|
||||||
|
telegramError.value = error.response.data.error;
|
||||||
|
} else {
|
||||||
|
telegramError.value = 'Error al guardar la configuración de Telegram';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingAction.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleAuthLogout() {
|
function handleAuthLogout() {
|
||||||
// Cuando el usuario se desconecta globalmente, limpiar datos
|
// Cuando el usuario se desconecta globalmente, limpiar datos
|
||||||
users.value = [];
|
users.value = [];
|
||||||
@@ -544,6 +705,13 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cargar configuración de Telegram cuando se abre el modal
|
||||||
|
watch(showTelegramModal, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
loadTelegramConfig();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('auth-logout', handleAuthLogout);
|
window.removeEventListener('auth-logout', handleAuthLogout);
|
||||||
window.removeEventListener('auth-login', loadUsers);
|
window.removeEventListener('auth-login', loadUsers);
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||||
|
<div>
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Gestión de Workers</h1>
|
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Gestión de Workers</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Tus workers personalizados
|
||||||
|
<span v-if="currentUser" class="font-medium text-gray-700 dark:text-gray-300">(Usuario: {{ currentUser }})</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button @click="showGeneralModal = true" class="btn btn-secondary text-xs sm:text-sm">
|
<button @click="showGeneralModal = true" class="btn btn-secondary text-xs sm:text-sm">
|
||||||
⚙️ Configuración General
|
⚙️ Configuración General
|
||||||
</button>
|
</button>
|
||||||
<button @click="handleClearCache" class="btn btn-secondary text-xs sm:text-sm">
|
|
||||||
🗑️ Limpiar Caché
|
|
||||||
</button>
|
|
||||||
<button @click="showAddModal = true" class="btn btn-primary text-xs sm:text-sm whitespace-nowrap">
|
<button @click="showAddModal = true" class="btn btn-primary text-xs sm:text-sm whitespace-nowrap">
|
||||||
+ Añadir Worker
|
+ Añadir Worker
|
||||||
</button>
|
</button>
|
||||||
@@ -116,7 +119,7 @@
|
|||||||
🗑️ Eliminar
|
🗑️ Eliminar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="disableWorker(worker.name)"
|
@click="disableWorker(worker)"
|
||||||
class="btn btn-secondary text-xs sm:text-sm flex-1 sm:flex-none"
|
class="btn btn-secondary text-xs sm:text-sm flex-1 sm:flex-none"
|
||||||
>
|
>
|
||||||
⏸️ Desactivar
|
⏸️ Desactivar
|
||||||
@@ -157,7 +160,7 @@
|
|||||||
✏️ Editar
|
✏️ Editar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="enableWorker(worker.name)"
|
@click="enableWorker(worker)"
|
||||||
class="btn btn-primary text-xs sm:text-sm flex-1 sm:flex-none"
|
class="btn btn-primary text-xs sm:text-sm flex-1 sm:flex-none"
|
||||||
>
|
>
|
||||||
▶️ Activar
|
▶️ Activar
|
||||||
@@ -175,7 +178,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="activeWorkers.length === 0 && disabledWorkers.length === 0" class="card text-center py-12">
|
<div v-if="activeWorkers.length === 0 && disabledWorkers.length === 0" class="card text-center py-12">
|
||||||
<p class="text-gray-600 dark:text-gray-400">No hay workers configurados</p>
|
<p class="text-gray-600 dark:text-gray-400 mb-2">No tienes workers configurados</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
Los workers son personales para cada usuario. Cada usuario gestiona sus propias búsquedas.
|
||||||
|
</p>
|
||||||
<button @click="showAddModal = true" class="btn btn-primary mt-4">
|
<button @click="showAddModal = true" class="btn btn-primary mt-4">
|
||||||
+ Crear primer worker
|
+ Crear primer worker
|
||||||
</button>
|
</button>
|
||||||
@@ -385,7 +391,10 @@
|
|||||||
>
|
>
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 sm:p-6 max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
|
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 sm:p-6 max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
|
||||||
<h2 class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Configuración General</h2>
|
<h2 class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Configuración General</h2>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Estas configuraciones se aplican a todos los workers</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Estas configuraciones se aplican a todos tus workers.
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">Los filtros globales se combinan con los filtros específicos de cada worker.</span>
|
||||||
|
</p>
|
||||||
<form @submit.prevent="saveGeneralConfig" class="space-y-4">
|
<form @submit.prevent="saveGeneralConfig" class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Excluir palabras del título (global)</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Excluir palabras del título (global)</label>
|
||||||
@@ -422,22 +431,32 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
import authService from '../services/auth';
|
||||||
|
|
||||||
const workers = ref({ items: [], disabled: [], general: {} });
|
const workers = ref({ items: [], disabled: [], general: {} });
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const showAddModal = ref(false);
|
const showAddModal = ref(false);
|
||||||
const showGeneralModal = ref(false);
|
const showGeneralModal = ref(false);
|
||||||
const editingWorker = ref(null);
|
const editingWorker = ref(null);
|
||||||
|
const currentUser = ref(authService.getUsername() || null);
|
||||||
|
|
||||||
const activeWorkers = computed(() => {
|
const activeWorkers = computed(() => {
|
||||||
return workers.value.items?.filter(
|
return workers.value.items?.filter(
|
||||||
w => !workers.value.disabled?.includes(w.name)
|
w => {
|
||||||
|
const workerId = w.id || w.worker_id;
|
||||||
|
const workerName = w.name;
|
||||||
|
return !workers.value.disabled?.includes(workerId) && !workers.value.disabled?.includes(workerName);
|
||||||
|
}
|
||||||
) || [];
|
) || [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const disabledWorkers = computed(() => {
|
const disabledWorkers = computed(() => {
|
||||||
return workers.value.items?.filter(
|
return workers.value.items?.filter(
|
||||||
w => workers.value.disabled?.includes(w.name)
|
w => {
|
||||||
|
const workerId = w.id || w.worker_id;
|
||||||
|
const workerName = w.name;
|
||||||
|
return workers.value.disabled?.includes(workerId) || workers.value.disabled?.includes(workerName);
|
||||||
|
}
|
||||||
) || [];
|
) || [];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -613,6 +632,7 @@ async function saveWorker() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const workerData = {
|
const workerData = {
|
||||||
|
id: editingWorker.value ? (workers.value.items[editingWorker.value.index]?.id || workers.value.items[editingWorker.value.index]?.worker_id || crypto.randomUUID()) : crypto.randomUUID(),
|
||||||
name: workerForm.value.name,
|
name: workerForm.value.name,
|
||||||
platform: workerForm.value.platform,
|
platform: workerForm.value.platform,
|
||||||
search_query: workerForm.value.search_query,
|
search_query: workerForm.value.search_query,
|
||||||
@@ -631,11 +651,15 @@ async function saveWorker() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (editingWorker.value) {
|
if (editingWorker.value) {
|
||||||
// Editar worker existente
|
// Editar worker existente - mantener el ID existente
|
||||||
const index = editingWorker.value.index;
|
const index = editingWorker.value.index;
|
||||||
|
const existingId = workers.value.items[index]?.id || workers.value.items[index]?.worker_id;
|
||||||
|
if (existingId) {
|
||||||
|
workerData.id = existingId;
|
||||||
|
}
|
||||||
updatedWorkers.items[index] = workerData;
|
updatedWorkers.items[index] = workerData;
|
||||||
} else {
|
} else {
|
||||||
// Añadir nuevo worker
|
// Añadir nuevo worker con ID único
|
||||||
updatedWorkers.items.push(workerData);
|
updatedWorkers.items.push(workerData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,8 +693,10 @@ async function saveGeneralConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disableWorker(name) {
|
async function disableWorker(worker) {
|
||||||
if (!confirm(`¿Desactivar el worker "${name}"?`)) {
|
const workerId = worker.id || worker.worker_id;
|
||||||
|
const workerName = worker.name;
|
||||||
|
if (!confirm(`¿Desactivar el worker "${workerName}"?`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,8 +707,14 @@ async function disableWorker(name) {
|
|||||||
disabled: [...(workers.value.disabled || [])]
|
disabled: [...(workers.value.disabled || [])]
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!updatedWorkers.disabled.includes(name)) {
|
// Usar ID si existe, sino usar nombre (para compatibilidad)
|
||||||
updatedWorkers.disabled.push(name);
|
const identifier = workerId || workerName;
|
||||||
|
|
||||||
|
// Eliminar cualquier referencia antigua (por nombre o ID)
|
||||||
|
updatedWorkers.disabled = updatedWorkers.disabled.filter(d => d !== workerId && d !== workerName && d !== worker.worker_id);
|
||||||
|
|
||||||
|
if (!updatedWorkers.disabled.includes(identifier)) {
|
||||||
|
updatedWorkers.disabled.push(identifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.updateWorkers(updatedWorkers);
|
await api.updateWorkers(updatedWorkers);
|
||||||
@@ -693,12 +725,15 @@ async function disableWorker(name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enableWorker(name) {
|
async function enableWorker(worker) {
|
||||||
|
const workerId = worker.id || worker.worker_id;
|
||||||
|
const workerName = worker.name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedWorkers = {
|
const updatedWorkers = {
|
||||||
...workers.value,
|
...workers.value,
|
||||||
items: workers.value.items || [],
|
items: workers.value.items || [],
|
||||||
disabled: [...(workers.value.disabled || [])].filter(n => n !== name)
|
disabled: [...(workers.value.disabled || [])].filter(d => d !== workerId && d !== workerName && d !== worker.worker_id)
|
||||||
};
|
};
|
||||||
|
|
||||||
await api.updateWorkers(updatedWorkers);
|
await api.updateWorkers(updatedWorkers);
|
||||||
@@ -730,26 +765,11 @@ async function deleteWorker(name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleClearCache() {
|
|
||||||
if (!confirm('¿Estás seguro de que quieres limpiar toda la caché de Redis?\n\nEsto eliminará todos los artículos notificados de todas las instancias. Esta acción no se puede deshacer.')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await api.clearCache();
|
|
||||||
const message = result.count > 0
|
|
||||||
? `✓ Caché limpiada exitosamente: ${result.count} artículos eliminados`
|
|
||||||
: 'La caché ya estaba vacía';
|
|
||||||
alert(message);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error limpiando caché:', error);
|
|
||||||
alert('Error al limpiar la caché: ' + (error.response?.data?.error || error.message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleWSMessage(event) {
|
function handleWSMessage(event) {
|
||||||
const data = event.detail;
|
const data = event.detail;
|
||||||
if (data.type === 'workers_updated') {
|
if (data.type === 'workers_updated') {
|
||||||
|
// Solo actualizar si es para el usuario actual (o si no especifica usuario)
|
||||||
|
if (!data.username || data.username === currentUser.value) {
|
||||||
workers.value = data.data;
|
workers.value = data.data;
|
||||||
// Actualizar formulario general
|
// Actualizar formulario general
|
||||||
generalForm.value = {
|
generalForm.value = {
|
||||||
@@ -757,14 +777,28 @@ function handleWSMessage(event) {
|
|||||||
description_exclude_text: arrayToText(workers.value.general?.description_exclude || []),
|
description_exclude_text: arrayToText(workers.value.general?.description_exclude || []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escuchar cambios de autenticación
|
||||||
|
function handleAuthChange() {
|
||||||
|
currentUser.value = authService.getUsername() || null;
|
||||||
|
// Recargar workers si cambia el usuario
|
||||||
|
if (currentUser.value) {
|
||||||
|
loadWorkers();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadWorkers();
|
loadWorkers();
|
||||||
window.addEventListener('ws-message', handleWSMessage);
|
window.addEventListener('ws-message', handleWSMessage);
|
||||||
|
window.addEventListener('auth-logout', handleAuthChange);
|
||||||
|
window.addEventListener('auth-login', handleAuthChange);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('ws-message', handleWSMessage);
|
window.removeEventListener('ws-message', handleWSMessage);
|
||||||
|
window.removeEventListener('auth-logout', handleAuthChange);
|
||||||
|
window.removeEventListener('auth-login', handleAuthChange);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -20,6 +20,33 @@ export default {
|
|||||||
800: '#075985',
|
800: '#075985',
|
||||||
900: '#0c4a6e',
|
900: '#0c4a6e',
|
||||||
},
|
},
|
||||||
|
// Metronic-inspired color palette
|
||||||
|
gray: {
|
||||||
|
50: '#f9fafb',
|
||||||
|
100: '#f3f4f6',
|
||||||
|
200: '#e5e7eb',
|
||||||
|
300: '#d1d5db',
|
||||||
|
400: '#9ca3af',
|
||||||
|
500: '#6b7280',
|
||||||
|
600: '#4b5563',
|
||||||
|
700: '#374151',
|
||||||
|
800: '#1f2937',
|
||||||
|
900: '#111827',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'sm': '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||||
|
'DEFAULT': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
||||||
|
'md': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||||
|
'lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||||
|
'xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||||
|
'2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||||
|
},
|
||||||
|
transitionDuration: {
|
||||||
|
'DEFAULT': '200ms',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
6
web/package-lock.json
generated
Normal file
6
web/package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user