feat: push notifications

Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
Omar Sánchez Pizarro
2026-01-19 23:27:02 +01:00
parent ed4062b350
commit 9a61f16959
8 changed files with 683 additions and 9 deletions

View File

@@ -1,5 +1,5 @@
{ {
"name": "wallabicher-backend", "name": "wallabicher-backend",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
@@ -13,6 +13,7 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"redis": "^4.6.10", "redis": "^4.6.10",
"web-push": "^3.6.7",
"ws": "^8.14.2", "ws": "^8.14.2",
"yaml": "^2.3.4" "yaml": "^2.3.4"
} }
@@ -89,6 +90,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/anymatch": { "node_modules/anymatch": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -108,6 +118,18 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -120,6 +142,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"license": "MIT"
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -156,6 +184,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -318,6 +352,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -589,6 +632,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -609,6 +661,42 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/https-proxy-agent/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -678,6 +766,27 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -747,6 +856,21 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"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",
@@ -1131,6 +1255,25 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/ws": { "node_modules/ws": {
"version": "8.19.0", "version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",

View File

@@ -8,16 +8,20 @@
"start": "node server.js", "start": "node server.js",
"dev": "node --watch server.js" "dev": "node --watch server.js"
}, },
"keywords": ["wallabicher", "api", "express"], "keywords": [
"wallabicher",
"api",
"express"
],
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"express": "^4.18.2", "chokidar": "^3.5.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"ws": "^8.14.2", "express": "^4.18.2",
"redis": "^4.6.10", "redis": "^4.6.10",
"yaml": "^2.3.4", "web-push": "^3.6.7",
"chokidar": "^3.5.3" "ws": "^8.14.2",
"yaml": "^2.3.4"
} }
} }

View File

@@ -8,6 +8,7 @@ import { readFileSync, writeFileSync, existsSync, statSync } from 'fs';
import { watch } from 'chokidar'; import { watch } from 'chokidar';
import yaml from 'yaml'; import yaml from 'yaml';
import redis from 'redis'; import redis from 'redis';
import webpush from 'web-push';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -24,6 +25,37 @@ app.use(express.json());
// Configuración // Configuración
const CONFIG_PATH = join(PROJECT_ROOT, 'config.yaml'); const CONFIG_PATH = join(PROJECT_ROOT, 'config.yaml');
const WORKERS_PATH = join(PROJECT_ROOT, 'workers.json'); const WORKERS_PATH = join(PROJECT_ROOT, 'workers.json');
const PUSH_SUBSCRIPTIONS_PATH = join(PROJECT_ROOT, 'push-subscriptions.json');
// Inicializar VAPID keys para Web Push
let vapidKeys = null;
const VAPID_KEYS_PATH = join(PROJECT_ROOT, 'vapid-keys.json');
function initVAPIDKeys() {
try {
if (existsSync(VAPID_KEYS_PATH)) {
vapidKeys = JSON.parse(readFileSync(VAPID_KEYS_PATH, 'utf8'));
console.log('✅ VAPID keys cargadas desde archivo');
} else {
// Generar nuevas VAPID keys
vapidKeys = webpush.generateVAPIDKeys();
writeFileSync(VAPID_KEYS_PATH, JSON.stringify(vapidKeys, null, 2), 'utf8');
console.log('✅ Nuevas VAPID keys generadas y guardadas');
}
// Configurar web-push con las VAPID keys
webpush.setVapidDetails(
'mailto:admin@pribyte.cloud', // Contacto (puedes cambiarlo)
vapidKeys.publicKey,
vapidKeys.privateKey
);
} catch (error) {
console.error('Error inicializando VAPID keys:', error.message);
}
}
// Inicializar VAPID keys al arrancar
initVAPIDKeys();
// Función para obtener la ruta del log (en Docker puede estar en /data/logs) // Función para obtener la ruta del log (en Docker puede estar en /data/logs)
function getLogPath() { function getLogPath() {
@@ -585,6 +617,112 @@ app.get('/api/telegram/threads', async (req, res) => {
} }
}); });
// Obtener suscripciones push guardadas
function getPushSubscriptions() {
return readJSON(PUSH_SUBSCRIPTIONS_PATH, []);
}
// Guardar suscripciones push
function savePushSubscriptions(subscriptions) {
return writeJSON(PUSH_SUBSCRIPTIONS_PATH, subscriptions);
}
// API Routes para Push Notifications
// Obtener clave pública VAPID
app.get('/api/push/public-key', (req, res) => {
if (!vapidKeys || !vapidKeys.publicKey) {
return res.status(500).json({ error: 'VAPID keys no están configuradas' });
}
res.json({ publicKey: vapidKeys.publicKey });
});
// Suscribirse a notificaciones push
app.post('/api/push/subscribe', async (req, res) => {
try {
const subscription = req.body;
if (!subscription || !subscription.endpoint) {
return res.status(400).json({ error: 'Suscripción inválida' });
}
const subscriptions = getPushSubscriptions();
// Verificar si ya existe esta suscripción
const existingIndex = subscriptions.findIndex(
sub => sub.endpoint === subscription.endpoint
);
if (existingIndex >= 0) {
subscriptions[existingIndex] = subscription;
} else {
subscriptions.push(subscription);
}
savePushSubscriptions(subscriptions);
console.log(`✅ Nueva suscripción push guardada. Total: ${subscriptions.length}`);
res.json({ success: true, totalSubscriptions: subscriptions.length });
} catch (error) {
console.error('Error guardando suscripción push:', error);
res.status(500).json({ error: error.message });
}
});
// Cancelar suscripción push
app.post('/api/push/unsubscribe', async (req, res) => {
try {
const subscription = req.body;
if (!subscription || !subscription.endpoint) {
return res.status(400).json({ error: 'Suscripción inválida' });
}
const subscriptions = getPushSubscriptions();
const filtered = subscriptions.filter(
sub => sub.endpoint !== subscription.endpoint
);
savePushSubscriptions(filtered);
console.log(`✅ Suscripción push cancelada. Total: ${filtered.length}`);
res.json({ success: true, totalSubscriptions: filtered.length });
} catch (error) {
console.error('Error cancelando suscripción push:', error);
res.status(500).json({ error: error.message });
}
});
// Enviar notificación push a todas las suscripciones
async function sendPushNotifications(notificationData) {
const subscriptions = getPushSubscriptions();
if (subscriptions.length === 0) {
return;
}
const payload = JSON.stringify(notificationData);
const promises = subscriptions.map(async (subscription) => {
try {
await webpush.sendNotification(subscription, payload);
console.log('✅ Notificación push enviada');
} catch (error) {
console.error('Error enviando notificación push:', error);
// Si la suscripción es inválida (404, 410), eliminarla
if (error.statusCode === 404 || error.statusCode === 410) {
const updatedSubscriptions = getPushSubscriptions().filter(
sub => sub.endpoint !== subscription.endpoint
);
savePushSubscriptions(updatedSubscriptions);
console.log(`Suscripción inválida eliminada. Total: ${updatedSubscriptions.length}`);
}
}
});
await Promise.allSettled(promises);
}
// WebSocket connection // WebSocket connection
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
console.log('Cliente WebSocket conectado'); console.log('Cliente WebSocket conectado');
@@ -684,6 +822,21 @@ async function checkForNewArticles() {
type: 'new_articles', type: 'new_articles',
data: newArticles data: newArticles
}); });
// Enviar notificaciones push para cada artículo nuevo
for (const article of newArticles) {
await sendPushNotifications({
title: `Nuevo artículo en ${article.platform?.toUpperCase() || 'Wallabicher'}`,
body: article.title || 'Artículo nuevo disponible',
icon: '/android-chrome-192x192.png',
image: article.images?.[0] || null,
url: article.url || '/',
platform: article.platform,
price: article.price,
currency: article.currency || '€',
id: article.id,
});
}
} }
// Actualizar el set de claves notificadas // Actualizar el set de claves notificadas

View File

@@ -17,6 +17,8 @@
"theme_color": "#0284c7", "theme_color": "#0284c7",
"background_color": "#ffffff", "background_color": "#ffffff",
"display": "standalone", "display": "standalone",
"start_url": "/" "start_url": "/",
"scope": "/",
"gcm_sender_id": "103953800507"
} }

100
web/frontend/public/sw.js Normal file
View File

@@ -0,0 +1,100 @@
// Service Worker para notificaciones push
const CACHE_NAME = 'wallabicher-v1';
// Instalar Service Worker
self.addEventListener('install', (event) => {
console.log('Service Worker instalado');
self.skipWaiting();
});
// Activar Service Worker
self.addEventListener('activate', (event) => {
console.log('Service Worker activado');
event.waitUntil(self.clients.claim());
});
// Manejar mensajes push
self.addEventListener('push', (event) => {
console.log('Push recibido:', event);
let notificationData = {
title: 'Wallabicher',
body: 'Tienes nuevas notificaciones',
icon: '/android-chrome-192x192.png',
badge: '/android-chrome-192x192.png',
tag: 'wallabicher-notification',
requireInteraction: false,
data: {}
};
// Si hay datos en el push, usarlos
if (event.data) {
try {
const data = event.data.json();
if (data.title) notificationData.title = data.title;
if (data.body) notificationData.body = data.body;
if (data.icon) notificationData.icon = data.icon;
if (data.image) notificationData.image = data.image;
if (data.url) notificationData.data.url = data.url;
if (data.platform) notificationData.data.platform = data.platform;
if (data.price) notificationData.data.price = data.price;
if (data.currency) notificationData.data.currency = data.currency;
// Usar tag único para agrupar notificaciones por artículo
if (data.id) notificationData.tag = `article-${data.id}`;
} catch (e) {
// Si no es JSON, usar como texto
notificationData.body = event.data.text();
}
}
event.waitUntil(
self.registration.showNotification(notificationData.title, {
body: notificationData.body,
icon: notificationData.icon,
badge: notificationData.badge,
image: notificationData.image,
tag: notificationData.tag,
requireInteraction: notificationData.requireInteraction,
data: notificationData.data,
actions: notificationData.data.url ? [
{
action: 'open',
title: 'Ver artículo'
},
{
action: 'close',
title: 'Cerrar'
}
] : []
})
);
});
// Manejar clics en notificaciones
self.addEventListener('notificationclick', (event) => {
console.log('Notificación clickeada:', event);
event.notification.close();
if (event.action === 'close') {
return;
}
// Si hay una URL, abrirla
if (event.notification.data.url) {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
} else {
// Si no hay URL, abrir la app
event.waitUntil(
clients.openWindow('/')
);
}
});
// Manejar notificaciones cerradas
self.addEventListener('notificationclose', (event) => {
console.log('Notificación cerrada:', event);
});

View File

@@ -21,6 +21,22 @@
</div> </div>
</div> </div>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<button
v-if="!pushEnabled"
@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"
title="Activar notificaciones push"
>
<BellIcon class="w-5 h-5" />
</button>
<button
v-else
@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"
title="Desactivar notificaciones push"
>
<BellSlashIcon class="w-5 h-5" />
</button>
<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-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"
@@ -158,7 +174,10 @@ import {
XMarkIcon, XMarkIcon,
SunIcon, SunIcon,
MoonIcon, MoonIcon,
BellIcon,
BellSlashIcon,
} from '@heroicons/vue/24/outline'; } from '@heroicons/vue/24/outline';
import pushNotificationService from './services/pushNotifications';
const navItems = [ const navItems = [
{ path: '/', name: 'Dashboard', icon: HomeIcon }, { path: '/', name: 'Dashboard', icon: HomeIcon },
@@ -172,6 +191,7 @@ const wsConnected = ref(false);
const mobileMenuOpen = ref(false); const mobileMenuOpen = ref(false);
const darkMode = ref(false); const darkMode = ref(false);
const toasts = ref([]); const toasts = ref([]);
const pushEnabled = ref(false);
let ws = null; let ws = null;
let toastIdCounter = 0; let toastIdCounter = 0;
@@ -219,9 +239,52 @@ function initDarkMode() {
} }
} }
onMounted(() => { async function enablePushNotifications() {
try {
const success = await pushNotificationService.init();
if (success) {
pushEnabled.value = true;
// Mostrar notificación simple usando la API de notificaciones del navegador
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('Notificaciones activadas', {
body: 'Ahora recibirás notificaciones push cuando haya nuevos artículos',
icon: '/android-chrome-192x192.png',
});
}
} else {
alert('No se pudieron activar las notificaciones push. Verifica que los permisos estén habilitados.');
}
} catch (error) {
console.error('Error activando notificaciones push:', error);
alert('Error activando notificaciones push: ' + error.message);
}
}
async function disablePushNotifications() {
try {
await pushNotificationService.unsubscribe();
pushEnabled.value = false;
// Mostrar notificación simple
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('Notificaciones desactivadas', {
body: 'Ya no recibirás notificaciones push',
icon: '/android-chrome-192x192.png',
});
}
} catch (error) {
console.error('Error desactivando notificaciones push:', error);
}
}
async function checkPushStatus() {
const hasSubscription = await pushNotificationService.checkSubscription();
pushEnabled.value = hasSubscription;
}
onMounted(async () => {
initDarkMode(); initDarkMode();
connectWebSocket(); connectWebSocket();
await checkPushStatus();
}); });
onUnmounted(() => { onUnmounted(() => {

View File

@@ -25,3 +25,17 @@ const app = createApp(App);
app.use(router); app.use(router);
app.mount('#app'); app.mount('#app');
// Registrar Service Worker automáticamente al cargar la app
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('Service Worker registrado:', registration.scope);
} catch (error) {
console.error('Error registrando Service Worker:', error);
}
});
}

View File

@@ -0,0 +1,195 @@
// Servicio para manejar notificaciones push
class PushNotificationService {
constructor() {
this.registration = null;
this.subscription = null;
}
// Registrar Service Worker
async registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
this.registration = registration;
console.log('Service Worker registrado:', registration.scope);
return registration;
} catch (error) {
console.error('Error registrando Service Worker:', error);
return null;
}
} else {
console.warn('Service Workers no están soportados en este navegador');
return null;
}
}
// Solicitar permisos de notificación
async requestPermission() {
if (!('Notification' in window)) {
console.warn('Este navegador no soporta notificaciones');
return 'denied';
}
if (Notification.permission === 'granted') {
return 'granted';
}
if (Notification.permission === 'denied') {
return 'denied';
}
try {
const permission = await Notification.requestPermission();
return permission;
} catch (error) {
console.error('Error solicitando permiso:', error);
return 'denied';
}
}
// Suscribirse a notificaciones push
async subscribe() {
if (!this.registration) {
await this.registerServiceWorker();
}
if (!this.registration) {
throw new Error('No se pudo registrar el Service Worker');
}
try {
// Verificar si ya existe una suscripción
this.subscription = await this.registration.pushManager.getSubscription();
if (this.subscription) {
console.log('Ya existe una suscripción push');
return this.subscription;
}
// Obtener la clave pública del servidor
const response = await fetch('/api/push/public-key');
const { publicKey } = await response.json();
// Convertir la clave pública a formato ArrayBuffer
const applicationServerKey = this.urlBase64ToUint8Array(publicKey);
// Crear nueva suscripción
this.subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
});
console.log('Suscripción push creada:', this.subscription);
// Enviar la suscripción al servidor
await this.sendSubscriptionToServer(this.subscription);
return this.subscription;
} catch (error) {
console.error('Error suscribiéndose a push:', error);
throw error;
}
}
// Enviar suscripción al servidor
async sendSubscriptionToServer(subscription) {
try {
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription),
});
if (!response.ok) {
throw new Error('Error enviando suscripción al servidor');
}
console.log('Suscripción enviada al servidor');
return await response.json();
} catch (error) {
console.error('Error enviando suscripción:', error);
throw error;
}
}
// Cancelar suscripción
async unsubscribe() {
if (this.subscription) {
try {
await this.subscription.unsubscribe();
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(this.subscription),
});
this.subscription = null;
console.log('Suscripción cancelada');
return true;
} catch (error) {
console.error('Error cancelando suscripción:', error);
return false;
}
}
return false;
}
// Verificar estado de suscripción
async checkSubscription() {
if (!this.registration) {
await this.registerServiceWorker();
}
if (!this.registration) {
return false;
}
try {
this.subscription = await this.registration.pushManager.getSubscription();
return !!this.subscription;
} catch (error) {
console.error('Error verificando suscripción:', error);
return false;
}
}
// Convertir clave pública de base64 URL a Uint8Array
urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Inicializar todo el proceso
async init() {
const permission = await this.requestPermission();
if (permission === 'granted') {
await this.registerServiceWorker();
try {
await this.subscribe();
return true;
} catch (error) {
console.error('Error inicializando notificaciones push:', error);
return false;
}
}
return false;
}
}
// Exportar instancia singleton
export default new PushNotificationService();