From 054d75b1f47f242a4116a8312034fe1a3fe9fa92 Mon Sep 17 00:00:00 2001 From: zv Date: Thu, 11 Jun 2026 19:17:13 +0200 Subject: [PATCH] Add Web Push warning notification backend --- .env.example | 4 + AGENTS.md | 7 +- README.md | 14 +- app/api/notifications/check/route.ts | 74 +++++++++ app/api/notifications/subscriptions/route.ts | 59 +++++++ app/api/notifications/vapid-key/route.ts | 11 ++ components/settings/settings-page.tsx | 128 ++++++++++++--- lib/i18n.tsx | 22 ++- lib/notification-api.ts | 43 +++++ lib/push-service.ts | 71 +++++++++ lib/push-store.ts | 51 ++++++ lib/server-warnings.ts | 15 ++ package-lock.json | 157 ++++++++++++++++++- package.json | 2 + types/notifications.ts | 23 +++ 15 files changed, 645 insertions(+), 36 deletions(-) create mode 100644 .env.example create mode 100644 app/api/notifications/check/route.ts create mode 100644 app/api/notifications/subscriptions/route.ts create mode 100644 app/api/notifications/vapid-key/route.ts create mode 100644 lib/notification-api.ts create mode 100644 lib/push-service.ts create mode 100644 lib/push-store.ts create mode 100644 lib/server-warnings.ts create mode 100644 types/notifications.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1585a33 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +WEB_PUSH_VAPID_PUBLIC_KEY= +WEB_PUSH_VAPID_PRIVATE_KEY= +WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com +NOTIFICATIONS_CRON_SECRET= diff --git a/AGENTS.md b/AGENTS.md index 0360cd4..d60480d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,15 +4,15 @@ `wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz prognozę modelową łączącą IMGW ALARO z Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika. -Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts i Lucide React. PWA korzysta z manifestu oraz własnego service workera. +Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts, Lucide React i `web-push`. PWA korzysta z manifestu oraz własnego service workera. Najważniejsze katalogi: - `app/` - routing, layout, globalne style i route handlery proxy. - `components/` - komponenty pogody, prognozy, hydro, ostrzeżeń, layoutu, UI i stanów ekranu. - `hooks/` - hooki TanStack Query. -- `lib/` - fetchery, normalizacja danych, tłumaczenia, helpery i store Zustand. -- `types/` - typy danych IMGW, prognozy oraz lokalizacji. +- `lib/` - fetchery, normalizacja danych, tłumaczenia, helpery, Web Push i store Zustand. +- `types/` - typy danych IMGW, prognozy, lokalizacji oraz powiadomień. - `public/` - manifest, service worker i ikony PWA. ## Komendy @@ -45,6 +45,7 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for - Ostrzeżenia hydro zawierają jawne województwa, a ostrzeżenia meteo kody powiatów TERYT. Normalizuj oba warianty przez `lib/provinces.ts`; nie filtruj ostrzeżeń wyłącznie po opisach tekstowych. - Listy ostrzeżeń zachowują priorytet lokalnego województwa, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych. - Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego województwa. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony. +- Powiadomienia Web Push o ostrzeżeniach meteo konfiguruj przez `/settings`, `public/sw.js` i route handlery `app/api/notifications/*`. Wymagają kluczy VAPID w zmiennych środowiskowych. Obecny `lib/push-store.ts` jest pamięciowy i przed produkcją musi zostać zastąpiony trwałym magazynem subskrypcji. - GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim. - Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją. - Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states. diff --git a/README.md b/README.md index 1e1d1bc..7e73344 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Widok ostrzeżeń priorytetyzuje komunikaty dla województwa wynikającego z mie - Lucide React - TanStack Query - Zustand z trwałym stanem `localStorage` +- web-push do wysyłki Web Push - własny service worker, manifest i offline fallback ## Uruchomienie @@ -128,7 +129,18 @@ public/ manifest, ikony i service worker Manifest znajduje się w `public/manifest.json`, a service worker w `public/sw.js`. Rejestracja service workera działa w buildzie produkcyjnym. Powłoka aplikacji ma podstawowy offline fallback. Odpowiedzi API mogą być dostępne z pamięci urządzenia przy braku sieci, ale UI nadal prezentuje czas pomiaru i status świeżości. -Widok `/settings` zawiera pierwszy etap konfiguracji powiadomień o ostrzeżeniach meteorologicznych IMGW: wybór województwa, preferencję alertów i sprawdzenie zgody Web Push na urządzeniu. Na iOS powiadomienia webowe wymagają dodania PWA do ekranu początkowego i uruchomienia aplikacji z ikony. Realna wysyłka powiadomień wymaga jeszcze backendowego zapisu subskrypcji Web Push, kluczy VAPID oraz cyklicznego sprawdzania endpointu IMGW `warningsmeteo`; obecny UI nie wysyła jeszcze alertów. +Widok `/settings` zawiera konfigurację powiadomień o ostrzeżeniach meteorologicznych IMGW: wybór województwa, zgodę Web Push i zapis subskrypcji urządzenia. Na iOS powiadomienia webowe wymagają dodania PWA do ekranu początkowego i uruchomienia aplikacji z ikony. Endpoint `/api/notifications/check` sprawdza `warningsmeteo` i wysyła tylko nowe ostrzeżenia meteo do pasujących subskrypcji. Obecny magazyn subskrypcji działa w pamięci procesu, więc produkcja wymaga podmiany `lib/push-store.ts` na trwałą bazę lub KV. + +Do wysyłki Web Push wymagane są zmienne środowiskowe: + +```bash +WEB_PUSH_VAPID_PUBLIC_KEY= +WEB_PUSH_VAPID_PRIVATE_KEY= +WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com +NOTIFICATIONS_CRON_SECRET= +``` + +Klucze VAPID można wygenerować poleceniem `npx web-push generate-vapid-keys`. Cron powinien wywoływać `GET /api/notifications/check` z nagłówkiem `Authorization: Bearer ` albo `x-cron-secret`. ## Wdrożenie na Vercel diff --git a/app/api/notifications/check/route.ts b/app/api/notifications/check/route.ts new file mode 100644 index 0000000..e2eee98 --- /dev/null +++ b/app/api/notifications/check/route.ts @@ -0,0 +1,74 @@ +import { NextResponse } from "next/server"; +import { fetchMeteoWarnings } from "@/lib/server-warnings"; +import { getPushSubscriptions, hasSentWarning, markWarningSent, pruneSentWarnings, removePushSubscription } from "@/lib/push-store"; +import { isWebPushConfigured, sendWarningNotification } from "@/lib/push-service"; +import type { WeatherWarning } from "@/types/imgw"; + +export const runtime = "nodejs"; + +function getTimestamp(value: string | null) { + if (!value) return null; + const timestamp = new Date(value).getTime(); + return Number.isNaN(timestamp) ? null : timestamp; +} + +function isRelevantWarning(warning: WeatherWarning, now: number) { + const validTo = getTimestamp(warning.validTo); + return warning.kind === "meteo" && (validTo === null || validTo > now); +} + +function isCronAuthorized(request: Request) { + const secret = process.env.NOTIFICATIONS_CRON_SECRET; + if (!secret) return process.env.NODE_ENV !== "production"; + const authHeader = request.headers.get("authorization"); + const secretHeader = request.headers.get("x-cron-secret"); + return authHeader === `Bearer ${secret}` || secretHeader === secret; +} + +export async function GET(request: Request) { + if (!isCronAuthorized(request)) { + return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + } + if (!isWebPushConfigured()) { + return NextResponse.json({ error: "Web Push is not configured." }, { status: 503 }); + } + + const now = Date.now(); + const warnings = (await fetchMeteoWarnings(AbortSignal.timeout(12_000))).filter((warning) => isRelevantWarning(warning, now)); + const subscriptions = getPushSubscriptions().filter((subscription) => subscription.enabled); + const activeWarningIds = new Set(warnings.map((warning) => warning.id)); + let sent = 0; + let skipped = 0; + let failed = 0; + + pruneSentWarnings(activeWarningIds); + + for (const subscription of subscriptions) { + const matchingWarnings = warnings.filter((warning) => warning.provinces.includes(subscription.province)); + for (const warning of matchingWarnings) { + if (hasSentWarning(subscription.endpoint, warning.id)) { + skipped += 1; + continue; + } + + try { + await sendWarningNotification(subscription, warning); + markWarningSent(subscription.endpoint, warning.id); + sent += 1; + } catch (error) { + failed += 1; + const statusCode = typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null; + if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint); + } + } + } + + return NextResponse.json({ + ok: true, + subscriptions: subscriptions.length, + warnings: warnings.length, + sent, + skipped, + failed, + }); +} diff --git a/app/api/notifications/subscriptions/route.ts b/app/api/notifications/subscriptions/route.ts new file mode 100644 index 0000000..632da64 --- /dev/null +++ b/app/api/notifications/subscriptions/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { upsertPushSubscription, removePushSubscription } from "@/lib/push-store"; +import { isWebPushConfigured } from "@/lib/push-service"; +import { normalizeProvinceName } from "@/lib/provinces"; +import type { Language } from "@/lib/i18n"; +import type { BrowserPushSubscription } from "@/types/notifications"; + +export const runtime = "nodejs"; + +function isBrowserPushSubscription(value: unknown): value is BrowserPushSubscription { + if (!value || typeof value !== "object") return false; + const subscription = value as Partial; + return typeof subscription.endpoint === "string" + && subscription.endpoint.startsWith("https://") + && typeof subscription.keys?.p256dh === "string" + && typeof subscription.keys.auth === "string"; +} + +export async function POST(request: Request) { + if (!isWebPushConfigured()) { + return NextResponse.json({ error: "Web Push is not configured." }, { status: 503 }); + } + + const body = await request.json().catch(() => null) as { + subscription?: unknown; + province?: unknown; + language?: unknown; + enabled?: unknown; + } | null; + const subscription = body?.subscription; + const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null); + const language: Language = body?.language === "en" ? "en" : "pl"; + + if (!isBrowserPushSubscription(subscription) || !province) { + return NextResponse.json({ error: "Invalid push subscription." }, { status: 400 }); + } + + const now = new Date().toISOString(); + upsertPushSubscription({ + endpoint: subscription.endpoint, + subscription, + province, + language, + enabled: body?.enabled !== false, + createdAt: now, + updatedAt: now, + }); + + return NextResponse.json({ ok: true }); +} + +export async function DELETE(request: Request) { + const body = await request.json().catch(() => null) as { endpoint?: unknown } | null; + if (typeof body?.endpoint !== "string" || !body.endpoint) { + return NextResponse.json({ error: "Invalid push subscription endpoint." }, { status: 400 }); + } + removePushSubscription(body.endpoint); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/notifications/vapid-key/route.ts b/app/api/notifications/vapid-key/route.ts new file mode 100644 index 0000000..0b94ceb --- /dev/null +++ b/app/api/notifications/vapid-key/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { getVapidPublicKey, isWebPushConfigured } from "@/lib/push-service"; + +export const runtime = "nodejs"; + +export function GET() { + return NextResponse.json({ + configured: isWebPushConfigured(), + publicKey: getVapidPublicKey(), + }); +} diff --git a/components/settings/settings-page.tsx b/components/settings/settings-page.tsx index b71e23f..caaf6a3 100644 --- a/components/settings/settings-page.tsx +++ b/components/settings/settings-page.tsx @@ -8,11 +8,12 @@ import { LanguageToggle } from "@/components/ui/language-toggle"; import { ThemeToggle } from "@/components/ui/theme-toggle"; import { DEFAULT_STATION_ID } from "@/lib/constants"; import { useI18n } from "@/lib/i18n"; +import { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription } from "@/lib/notification-api"; import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces"; import { useWeatherStore } from "@/lib/store"; import type { Province } from "@/types/province"; -type NotificationSupportStatus = "checking" | "unsupported" | "needs-install" | "default" | "denied" | "granted"; +type NotificationSupportStatus = "checking" | "unconfigured" | "unsupported" | "needs-install" | "default" | "denied" | "granted"; function isStandaloneApp() { if (typeof window === "undefined") return false; @@ -47,6 +48,9 @@ function SettingsRow({ icon: Icon, title, description, children }: { icon: typeo export function SettingsPage() { const { language, t } = useI18n(); const [notificationStatus, setNotificationStatus] = useState("checking"); + const [vapidPublicKey, setVapidPublicKey] = useState(null); + const [isSavingSubscription, setIsSavingSubscription] = useState(false); + const [notificationMessage, setNotificationMessage] = useState(null); const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedLocation = useWeatherStore((state) => state.selectedLocation); const notificationsEnabled = useWeatherStore((state) => state.warningNotificationsEnabled); @@ -61,12 +65,44 @@ export function SettingsPage() { const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince"); useEffect(() => { - const animationFrame = window.requestAnimationFrame(() => setNotificationStatus(getNotificationSupportStatus())); - return () => window.cancelAnimationFrame(animationFrame); + const abortController = new AbortController(); + const animationFrame = window.requestAnimationFrame(() => { + setNotificationStatus(getNotificationSupportStatus()); + fetchVapidPublicKey(abortController.signal) + .then((config) => { + setVapidPublicKey(config.publicKey); + if (!config.configured) setNotificationStatus("unconfigured"); + }) + .catch(() => setNotificationStatus("unconfigured")); + }); + return () => { + abortController.abort(); + window.cancelAnimationFrame(animationFrame); + }; }, []); + useEffect(() => { + if (!notificationsEnabled || !vapidPublicKey || !effectiveProvince || notificationStatus !== "granted") return; + const province = effectiveProvince; + let cancelled = false; + + async function syncSubscriptionProvince() { + const registration = await navigator.serviceWorker?.getRegistration(); + const subscription = await registration?.pushManager.getSubscription(); + if (!subscription || cancelled) return; + await savePushSubscription(subscription, province, language).catch(() => undefined); + } + + syncSubscriptionProvince(); + return () => { + cancelled = true; + }; + }, [effectiveProvince, language, notificationStatus, notificationsEnabled, vapidPublicKey]); + const notificationStatusLabel = useMemo(() => { switch (notificationStatus) { + case "unconfigured": + return t("settings.notifications.statusUnconfigured"); case "unsupported": return t("settings.notifications.statusUnsupported"); case "needs-install": @@ -82,17 +118,65 @@ export function SettingsPage() { } }, [notificationStatus, t]); - const canRequestPermission = notificationStatus === "default"; - const canEnablePreference = notificationStatus === "granted"; + const canUsePush = Boolean(vapidPublicKey && effectiveProvince && notificationStatus !== "unsupported" && notificationStatus !== "needs-install" && notificationStatus !== "denied" && notificationStatus !== "unconfigured"); - async function requestNotificationPermission() { + async function getServiceWorkerRegistration() { + if (!("serviceWorker" in navigator)) throw new Error("Service worker is not available."); + return await navigator.serviceWorker.getRegistration() ?? await navigator.serviceWorker.register("/sw.js"); + } + + async function enableWarningNotifications() { + if (!vapidPublicKey || !effectiveProvince) { + setNotificationMessage(t("settings.notifications.saveError")); + return; + } if (!("Notification" in window)) { setNotificationStatus("unsupported"); return; } - const permission = await Notification.requestPermission(); - setNotificationStatus(permission); - setNotificationsEnabled(permission === "granted"); + setIsSavingSubscription(true); + setNotificationMessage(null); + try { + const permission = Notification.permission === "granted" ? "granted" : await Notification.requestPermission(); + setNotificationStatus(permission); + if (permission !== "granted") { + setNotificationsEnabled(false); + return; + } + const registration = await getServiceWorkerRegistration(); + const existingSubscription = await registration.pushManager.getSubscription(); + const subscription = existingSubscription ?? await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: decodeBase64UrlKey(vapidPublicKey), + }); + await savePushSubscription(subscription, effectiveProvince, language); + setNotificationsEnabled(true); + setNotificationMessage(t("settings.notifications.saveSuccess")); + } catch { + setNotificationsEnabled(false); + setNotificationMessage(t("settings.notifications.saveError")); + } finally { + setIsSavingSubscription(false); + } + } + + async function disableWarningNotifications() { + setIsSavingSubscription(true); + setNotificationMessage(null); + try { + const registration = await navigator.serviceWorker?.getRegistration(); + const subscription = await registration?.pushManager.getSubscription(); + if (subscription) { + await deletePushSubscription(subscription.endpoint).catch(() => undefined); + await subscription.unsubscribe(); + } + setNotificationsEnabled(false); + setNotificationMessage(t("settings.notifications.disableSuccess")); + } catch { + setNotificationMessage(t("settings.notifications.saveError")); + } finally { + setIsSavingSubscription(false); + } } return ( @@ -186,26 +270,20 @@ export function SettingsPage() { -
- +
+

{t("settings.notifications.enable")}

+

{t("settings.notifications.enableDescription")}

+ {notificationMessage &&

{notificationMessage}

}
-
diff --git a/lib/i18n.tsx b/lib/i18n.tsx index c6324d4..350d0ae 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -35,15 +35,22 @@ const translations = { "settings.notifications.noProvince": "brak wybranego województwa", "settings.notifications.deviceTitle": "To urządzenie", "settings.notifications.enable": "Alerty IMGW", - "settings.notifications.enableDescription": "Preferencja zostanie użyta po dodaniu backendowej subskrypcji Web Push.", + "settings.notifications.enableDescription": "Subskrypcja zostanie zapisana dla wybranego województwa i użyta przez serwerowy sprawdzacz ostrzeżeń.", + "settings.notifications.enableDevice": "Włącz na tym urządzeniu", + "settings.notifications.disable": "Wyłącz na tym urządzeniu", + "settings.notifications.saving": "Zapisuję…", + "settings.notifications.saveSuccess": "Powiadomienia są włączone dla tego urządzenia.", + "settings.notifications.disableSuccess": "Powiadomienia są wyłączone dla tego urządzenia.", + "settings.notifications.saveError": "Nie udało się zapisać subskrypcji powiadomień.", "settings.notifications.requestPermission": "Sprawdź zgodę", "settings.notifications.statusChecking": "Sprawdzam obsługę powiadomień w tej przeglądarce.", + "settings.notifications.statusUnconfigured": "Serwer nie ma jeszcze skonfigurowanych kluczy Web Push VAPID.", "settings.notifications.statusUnsupported": "Ta przeglądarka nie udostępnia Web Push dla tej aplikacji.", "settings.notifications.statusNeedsInstall": "Na iPhonie dodaj aplikację do ekranu początkowego przez Udostępnij → Dodaj do ekranu początkowego, a potem otwórz ją z ikony.", "settings.notifications.statusDenied": "Powiadomienia są zablokowane w ustawieniach systemu lub przeglądarki.", "settings.notifications.statusGranted": "To urządzenie ma zgodę na powiadomienia.", "settings.notifications.statusReady": "Możesz poprosić system o zgodę na powiadomienia.", - "settings.notifications.implementationNote": "To pierwszy etap konfiguracji. Wysyłka po nowych ostrzeżeniach IMGW wymaga jeszcze zapisania subskrypcji Web Push i cyklicznego sprawdzania API IMGW po stronie serwera.", + "settings.notifications.implementationNote": "Serwerowy sprawdzacz wysyła nowe ostrzeżenia meteo IMGW przez Web Push. W aktualnej wersji subskrypcje są przechowywane w pamięci procesu, więc produkcja wymaga podmiany magazynu na trwałą bazę lub KV.", "pwa.install": "Zainstaluj", "common.noData": "Brak danych", "common.loading": "Ładowanie danych", @@ -247,15 +254,22 @@ const translations = { "settings.notifications.noProvince": "no province selected", "settings.notifications.deviceTitle": "This device", "settings.notifications.enable": "IMGW alerts", - "settings.notifications.enableDescription": "This preference will be used after the Web Push backend subscription is added.", + "settings.notifications.enableDescription": "The subscription will be saved for the selected province and used by the server warning checker.", + "settings.notifications.enableDevice": "Enable on this device", + "settings.notifications.disable": "Disable on this device", + "settings.notifications.saving": "Saving…", + "settings.notifications.saveSuccess": "Notifications are enabled for this device.", + "settings.notifications.disableSuccess": "Notifications are disabled for this device.", + "settings.notifications.saveError": "Unable to save the notification subscription.", "settings.notifications.requestPermission": "Check permission", "settings.notifications.statusChecking": "Checking notification support in this browser.", + "settings.notifications.statusUnconfigured": "The server does not have Web Push VAPID keys configured yet.", "settings.notifications.statusUnsupported": "This browser does not provide Web Push for this app.", "settings.notifications.statusNeedsInstall": "On iPhone, add the app to the Home Screen using Share → Add to Home Screen, then open it from the icon.", "settings.notifications.statusDenied": "Notifications are blocked in system or browser settings.", "settings.notifications.statusGranted": "This device has notification permission.", "settings.notifications.statusReady": "You can ask the system for notification permission.", - "settings.notifications.implementationNote": "This is the first configuration stage. Sending alerts for new IMGW warnings still requires saving a Web Push subscription and polling the IMGW API on the server.", + "settings.notifications.implementationNote": "The server checker sends new IMGW meteorological warnings through Web Push. This version stores subscriptions in process memory, so production needs a durable database or KV store.", "pwa.install": "Install", "common.noData": "No data", "common.loading": "Loading data", diff --git a/lib/notification-api.ts b/lib/notification-api.ts new file mode 100644 index 0000000..c444842 --- /dev/null +++ b/lib/notification-api.ts @@ -0,0 +1,43 @@ +import type { Language } from "@/lib/i18n"; +import type { Province } from "@/types/province"; + +interface VapidKeyResponse { + configured: boolean; + publicKey: string | null; +} + +export async function fetchVapidPublicKey(signal?: AbortSignal) { + const response = await fetch("/api/notifications/vapid-key", { signal }); + if (!response.ok) throw new Error("Unable to load Web Push configuration."); + return response.json() as Promise; +} + +export async function savePushSubscription(subscription: PushSubscription, province: Province, language: Language) { + const response = await fetch("/api/notifications/subscriptions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + subscription: subscription.toJSON(), + province, + language, + enabled: true, + }), + }); + if (!response.ok) throw new Error("Unable to save push subscription."); +} + +export async function deletePushSubscription(endpoint: string) { + const response = await fetch("/api/notifications/subscriptions", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ endpoint }), + }); + if (!response.ok) throw new Error("Unable to delete push subscription."); +} + +export function decodeBase64UrlKey(value: string) { + const padding = "=".repeat((4 - (value.length % 4)) % 4); + const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/"); + const raw = window.atob(base64); + return Uint8Array.from(raw, (character) => character.charCodeAt(0)); +} diff --git a/lib/push-service.ts b/lib/push-service.ts new file mode 100644 index 0000000..a705f9f --- /dev/null +++ b/lib/push-service.ts @@ -0,0 +1,71 @@ +import webPush, { type PushSubscription } from "web-push"; +import type { Language } from "@/lib/i18n"; +import { formatProvinceName } from "@/lib/provinces"; +import type { WeatherWarning } from "@/types/imgw"; +import type { WarningPushSubscription } from "@/types/notifications"; + +const VAPID_PUBLIC_KEY = process.env.WEB_PUSH_VAPID_PUBLIC_KEY; +const VAPID_PRIVATE_KEY = process.env.WEB_PUSH_VAPID_PRIVATE_KEY; +const VAPID_SUBJECT = process.env.WEB_PUSH_VAPID_SUBJECT ?? "mailto:alerts@wtr.local"; + +let configured = false; + +export function getVapidPublicKey() { + return VAPID_PUBLIC_KEY ?? null; +} + +export function isWebPushConfigured() { + return Boolean(VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY); +} + +function ensureWebPushConfigured() { + if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) throw new Error("Web Push VAPID keys are not configured."); + if (!configured) { + webPush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY); + configured = true; + } +} + +function getWarningLevelLabel(warning: WeatherWarning, language: Language) { + if (warning.level === null) return language === "pl" ? "stopień nieokreślony" : "level not specified"; + return language === "pl" ? `stopień ${warning.level}` : `level ${warning.level}`; +} + +function formatWarningValidity(warning: WeatherWarning, language: Language) { + if (!warning.validFrom && !warning.validTo) return null; + const locale = language === "pl" ? "pl-PL" : "en-GB"; + const formatter = new Intl.DateTimeFormat(locale, { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + const from = warning.validFrom ? formatter.format(new Date(warning.validFrom)) : null; + const to = warning.validTo ? formatter.format(new Date(warning.validTo)) : null; + if (from && to) return language === "pl" ? `od ${from} do ${to}` : `from ${from} to ${to}`; + if (from) return language === "pl" ? `od ${from}` : `from ${from}`; + return language === "pl" ? `do ${to}` : `until ${to}`; +} + +function buildWarningPayload(preference: WarningPushSubscription, warning: WeatherWarning) { + const province = formatProvinceName(preference.province, preference.language); + const title = warning.title || (preference.language === "pl" ? "Ostrzeżenie meteorologiczne" : "Weather warning"); + const validity = formatWarningValidity(warning, preference.language); + const level = getWarningLevelLabel(warning, preference.language); + const bodyParts = preference.language === "pl" + ? [`${province}: ${level}`, validity, warning.probability !== null ? `prawdopodobieństwo ${warning.probability}%` : null] + : [`${province}: ${level}`, validity, warning.probability !== null ? `${warning.probability}% probability` : null]; + + return { + title: `IMGW: ${title}`, + body: bodyParts.filter(Boolean).join(" · "), + url: "/warnings", + warningId: warning.id, + }; +} + +export async function sendWarningNotification(preference: WarningPushSubscription, warning: WeatherWarning) { + ensureWebPushConfigured(); + const payload = JSON.stringify(buildWarningPayload(preference, warning)); + await webPush.sendNotification(preference.subscription as PushSubscription, payload); +} diff --git a/lib/push-store.ts b/lib/push-store.ts new file mode 100644 index 0000000..e66f380 --- /dev/null +++ b/lib/push-store.ts @@ -0,0 +1,51 @@ +import type { WarningPushSubscription } from "@/types/notifications"; + +interface PushStore { + subscriptions: Map; + sentWarningIds: Map>; +} + +const globalStore = globalThis as typeof globalThis & { __wtrPushStore?: PushStore }; + +function getStore() { + if (!globalStore.__wtrPushStore) { + globalStore.__wtrPushStore = { + subscriptions: new Map(), + sentWarningIds: new Map(), + }; + } + return globalStore.__wtrPushStore; +} + +export function upsertPushSubscription(subscription: WarningPushSubscription) { + getStore().subscriptions.set(subscription.endpoint, subscription); +} + +export function removePushSubscription(endpoint: string) { + const store = getStore(); + store.subscriptions.delete(endpoint); + store.sentWarningIds.delete(endpoint); +} + +export function getPushSubscriptions() { + return Array.from(getStore().subscriptions.values()); +} + +export function hasSentWarning(endpoint: string, warningId: string) { + return getStore().sentWarningIds.get(endpoint)?.has(warningId) ?? false; +} + +export function markWarningSent(endpoint: string, warningId: string) { + const store = getStore(); + const sentIds = store.sentWarningIds.get(endpoint) ?? new Set(); + sentIds.add(warningId); + store.sentWarningIds.set(endpoint, sentIds); +} + +export function pruneSentWarnings(activeWarningIds: Set) { + getStore().sentWarningIds.forEach((sentIds) => { + sentIds.forEach((warningId) => { + if (!activeWarningIds.has(warningId)) sentIds.delete(warningId); + }); + }); +} diff --git a/lib/server-warnings.ts b/lib/server-warnings.ts new file mode 100644 index 0000000..11eb95d --- /dev/null +++ b/lib/server-warnings.ts @@ -0,0 +1,15 @@ +import { normalizeWarning } from "@/lib/weather-utils"; +import type { RawWarning, WeatherWarning } from "@/types/imgw"; + +const IMGW_WARNINGS_METEO_URL = "https://danepubliczne.imgw.pl/api/data/warningsmeteo"; + +export async function fetchMeteoWarnings(signal?: AbortSignal): Promise { + const response = await fetch(IMGW_WARNINGS_METEO_URL, { + headers: { accept: "application/json" }, + next: { revalidate: 300 }, + signal, + }); + if (!response.ok) throw new Error("Unable to load IMGW meteorological warnings."); + const rows = await response.json() as RawWarning[]; + return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, "meteo", index)) : []; +} diff --git a/package-lock.json b/package-lock.json index 134042e..10430c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,12 +18,14 @@ "react-dom": "^19.1.0", "recharts": "^3.8.1", "tailwind-merge": "^2.5.5", + "web-push": "^3.6.7", "zustand": "^5.0.3" }, "devDependencies": { "@types/node": "^22.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/web-push": "^3.6.4", "autoprefixer": "^10.4.20", "eslint": "^9.17.0", "eslint-config-next": "^16.2.6", @@ -1569,6 +1571,16 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.60.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", @@ -2217,6 +2229,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "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/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2455,6 +2476,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2577,6 +2610,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", @@ -2635,6 +2674,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "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/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3072,7 +3117,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3187,6 +3231,15 @@ "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/electron-to-chromium": { "version": "1.5.364", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", @@ -4338,6 +4391,28 @@ "hermes-estree": "0.25.1" } }, + "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/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/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4385,6 +4460,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4959,6 +5040,27 @@ "node": ">=4.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/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5112,6 +5214,12 @@ "node": ">=8.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/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -5129,7 +5237,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5154,7 +5261,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -6135,6 +6241,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6170,6 +6296,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -7121,6 +7253,25 @@ "d3-timer": "^3.0.1" } }, + "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/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index f215949..3dc0d67 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,14 @@ "react-dom": "^19.1.0", "recharts": "^3.8.1", "tailwind-merge": "^2.5.5", + "web-push": "^3.6.7", "zustand": "^5.0.3" }, "devDependencies": { "@types/node": "^22.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/web-push": "^3.6.4", "autoprefixer": "^10.4.20", "eslint": "^9.17.0", "eslint-config-next": "^16.2.6", diff --git a/types/notifications.ts b/types/notifications.ts new file mode 100644 index 0000000..e4860f8 --- /dev/null +++ b/types/notifications.ts @@ -0,0 +1,23 @@ +import type { Language } from "@/lib/i18n"; +import type { Province } from "@/types/province"; + +export interface PushSubscriptionKeys { + p256dh: string; + auth: string; +} + +export interface BrowserPushSubscription { + endpoint: string; + expirationTime?: number | null; + keys: PushSubscriptionKeys; +} + +export interface WarningPushSubscription { + endpoint: string; + subscription: BrowserPushSubscription; + province: Province; + language: Language; + enabled: boolean; + createdAt: string; + updatedAt: string; +}