From 1b4f4a674ac533c0b51260ba35ab91dd2cf9486d Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Thu, 2 Apr 2026 12:38:10 +0200 Subject: Basic push notification system for service alerts Co-authored-by: Copilot --- src/frontend/public/pwa-worker.js | 247 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) (limited to 'src/frontend/public/pwa-worker.js') diff --git a/src/frontend/public/pwa-worker.js b/src/frontend/public/pwa-worker.js index 9427bd3..09755d3 100644 --- a/src/frontend/public/pwa-worker.js +++ b/src/frontend/public/pwa-worker.js @@ -84,3 +84,250 @@ async function handleStaticRequest(request) { return null; } } + +// --------------------------------------------------------------------------- +// IndexedDB helpers (inline โ€” classic SW scripts cannot use ES module imports) +// Schema must match app/utils/idb.ts +// --------------------------------------------------------------------------- + +const IDB_NAME = "enmarcha-sw"; +const IDB_VERSION = 1; + +function idbOpen() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(IDB_NAME, IDB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains("favorites")) { + db.createObjectStore("favorites", { keyPath: "key" }); + } + if (!db.objectStoreNames.contains("alertState")) { + db.createObjectStore("alertState", { keyPath: "alertId" }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function idbGet(db, storeName, key) { + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, "readonly"); + const req = tx.objectStore(storeName).get(key); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function idbPut(db, storeName, value) { + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, "readwrite"); + tx.objectStore(storeName).put(value); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +// --------------------------------------------------------------------------- +// Push notification handler +// --------------------------------------------------------------------------- + +self.addEventListener("push", (event) => { + event.waitUntil(handlePush(event)); +}); + +async function handlePush(event) { + let payload; + try { + payload = event.data.json(); + } catch { + return; + } + + const { + alertId, + version, + header, + description, + selectors = [], + effect, + } = payload; + + const db = await idbOpen(); + + // Check per-alert state โ€” skip if already shown at this version or silenced + const alertState = await idbGet(db, "alertState", alertId); + if (alertState) { + if (alertState.silenced) { + db.close(); + return; + } + if (alertState.lastVersion >= version) { + db.close(); + return; + } + } + + // Read favourites from IDB + const stopRec = await idbGet(db, "favorites", "favouriteStops"); + const routeRec = await idbGet(db, "favorites", "favouriteRoutes"); + const agencyRec = await idbGet(db, "favorites", "favouriteAgencies"); + db.close(); + + const favStops = stopRec?.ids ?? []; + const favRoutes = routeRec?.ids ?? []; + const favAgencies = agencyRec?.ids ?? []; + + const hasAnyFavourites = + favStops.length > 0 || favRoutes.length > 0 || favAgencies.length > 0; + + // If user has favourites, only show if a selector matches; otherwise show all (fail-open) + if (hasAnyFavourites) { + const matches = selectors.some((raw) => { + const hashIdx = raw.indexOf("#"); + if (hashIdx === -1) return false; + const type = raw.slice(0, hashIdx); + const id = raw.slice(hashIdx + 1); + if (type === "stop") return favStops.includes(id); + if (type === "route") return favRoutes.includes(id); + if (type === "agency") return favAgencies.includes(id); + return false; + }); + if (!matches) return; + } + + // Determine notification title and body (prefer user's browser language, fallback to "es") + const lang = (self.navigator?.language ?? "es").slice(0, 2); + const title = + header[lang] ?? + header["es"] ?? + Object.values(header)[0] ?? + "Alerta de servicio"; + const body = + description[lang] ?? + description["es"] ?? + Object.values(description)[0] ?? + ""; + + // Map effect to an emoji hint for better at-a-glance reading + const iconHint = + { + NoService: "๐Ÿšซ", + ReducedService: "โš ๏ธ", + SignificantDelays: "๐Ÿ•", + Detour: "โ†ฉ๏ธ", + AdditionalService: "โž•", + StopMoved: "๐Ÿ“", + }[effect] ?? "โ„น๏ธ"; + + // Save the new version so we don't re-show the same notification + const db2 = await idbOpen(); + await idbPut(db2, "alertState", { + alertId, + silenced: false, + lastVersion: version, + }); + db2.close(); + + // Build a deep-link from the first selector + let firstLink = "/"; + if (selectors.length > 0) { + const first = selectors[0]; + const hashIdx = first.indexOf("#"); + if (hashIdx !== -1) { + const type = first.slice(0, hashIdx); + const id = first.slice(hashIdx + 1); + if (type === "stop") firstLink = `/stops/${encodeURIComponent(id)}`; + else if (type === "route") + firstLink = `/routes/${encodeURIComponent(id)}`; + } + } + + await self.registration.showNotification(`${iconHint} ${title}`, { + body, + icon: "/icon-192.png", + badge: "/icon-monochrome-256.png", + tag: alertId, + data: { alertId, version, link: firstLink }, + actions: [ + { action: "open", title: "Ver detalles" }, + { action: "silence", title: "No mostrar mรกs" }, + ], + }); +} + +// --------------------------------------------------------------------------- +// Notification click handler +// --------------------------------------------------------------------------- + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + + if (event.action === "silence") { + event.waitUntil( + (async () => { + const { alertId, version } = event.notification.data ?? {}; + if (!alertId) return; + const db = await idbOpen(); + await idbPut(db, "alertState", { + alertId, + silenced: true, + lastVersion: version ?? 0, + }); + db.close(); + })() + ); + return; + } + + // Default / "open" action โ€” focus or open the app at the alert's deep link + const link = event.notification.data?.link ?? "/"; + event.waitUntil( + self.clients + .matchAll({ type: "window", includeUncontrolled: true }) + .then((clients) => { + for (const client of clients) { + if (client.url.includes(self.location.origin) && "focus" in client) { + client.navigate(link); + return client.focus(); + } + } + return self.clients.openWindow(link); + }) + ); +}); + +// --------------------------------------------------------------------------- +// Re-subscribe handler (fires when the push subscription is invalidated) +// --------------------------------------------------------------------------- + +self.addEventListener("pushsubscriptionchange", (event) => { + event.waitUntil( + (async () => { + const newSubscription = + event.newSubscription ?? + (await self.registration.pushManager.subscribe( + event.oldSubscription + ? { + userVisibleOnly: true, + applicationServerKey: + event.oldSubscription.options.applicationServerKey, + } + : { userVisibleOnly: true } + )); + + if (!newSubscription) return; + + const { endpoint, keys } = newSubscription.toJSON(); + await fetch("/api/push/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + endpoint, + p256Dh: keys?.p256dh, + auth: keys?.auth, + }), + }); + })() + ); +}); -- cgit v1.3