diff --git a/AGENTS.md b/AGENTS.md index d60480d..a3bdb7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +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. +- Powiadomienia Web Push o ostrzeżeniach meteo konfiguruj przez `/settings`, `public/sw.js` i route handlery `app/api/notifications/*`, w tym testowy `/api/notifications/test`. 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 7e73344..5f79f02 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ 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 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. +Widok `/settings` zawiera konfigurację powiadomień o ostrzeżeniach meteorologicznych IMGW: wybór województwa, zgodę Web Push, zapis subskrypcji urządzenia i wysłanie powiadomienia testowego. 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, a `/api/notifications/test` wysyła test na wskazany endpoint 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: diff --git a/app/api/notifications/test/route.ts b/app/api/notifications/test/route.ts new file mode 100644 index 0000000..03b662c --- /dev/null +++ b/app/api/notifications/test/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { getPushSubscription, removePushSubscription } from "@/lib/push-store"; +import { isWebPushConfigured, sendTestNotification } from "@/lib/push-service"; + +export const runtime = "nodejs"; + +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 { endpoint?: unknown } | null; + if (typeof body?.endpoint !== "string" || !body.endpoint) { + return NextResponse.json({ error: "Invalid push subscription endpoint." }, { status: 400 }); + } + + const subscription = getPushSubscription(body.endpoint); + if (!subscription || !subscription.enabled) { + return NextResponse.json({ error: "Push subscription was not found." }, { status: 404 }); + } + + try { + await sendTestNotification(subscription); + return NextResponse.json({ ok: true }); + } catch (error) { + 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({ error: "Unable to send test notification." }, { status: 502 }); + } +} diff --git a/components/settings/settings-page.tsx b/components/settings/settings-page.tsx index caaf6a3..95ecc4d 100644 --- a/components/settings/settings-page.tsx +++ b/components/settings/settings-page.tsx @@ -8,7 +8,7 @@ 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 { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription, sendTestPushNotification } from "@/lib/notification-api"; import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces"; import { useWeatherStore } from "@/lib/store"; import type { Province } from "@/types/province"; @@ -50,6 +50,7 @@ export function SettingsPage() { const [notificationStatus, setNotificationStatus] = useState("checking"); const [vapidPublicKey, setVapidPublicKey] = useState(null); const [isSavingSubscription, setIsSavingSubscription] = useState(false); + const [isSendingTestNotification, setIsSendingTestNotification] = useState(false); const [notificationMessage, setNotificationMessage] = useState(null); const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedLocation = useWeatherStore((state) => state.selectedLocation); @@ -179,6 +180,22 @@ export function SettingsPage() { } } + async function sendTestNotification() { + setIsSendingTestNotification(true); + setNotificationMessage(null); + try { + const registration = await navigator.serviceWorker?.getRegistration(); + const subscription = await registration?.pushManager.getSubscription(); + if (!subscription) throw new Error("Push subscription is not available."); + await sendTestPushNotification(subscription.endpoint); + setNotificationMessage(t("settings.notifications.testSuccess")); + } catch { + setNotificationMessage(t("settings.notifications.testError")); + } finally { + setIsSendingTestNotification(false); + } + } + return (
@@ -285,6 +302,10 @@ export function SettingsPage() { ? t("settings.notifications.disable") : t("settings.notifications.enableDevice")} +

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

diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 350d0ae..4ca77ac 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -42,6 +42,10 @@ const translations = { "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.sendTest": "Wyślij test", + "settings.notifications.sendingTest": "Wysyłam…", + "settings.notifications.testSuccess": "Wysłano powiadomienie testowe.", + "settings.notifications.testError": "Nie udało się wysłać powiadomienia testowego.", "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.", @@ -261,6 +265,10 @@ const translations = { "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.sendTest": "Send test", + "settings.notifications.sendingTest": "Sending…", + "settings.notifications.testSuccess": "Test notification sent.", + "settings.notifications.testError": "Unable to send the test notification.", "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.", diff --git a/lib/notification-api.ts b/lib/notification-api.ts index c444842..bc32f9b 100644 --- a/lib/notification-api.ts +++ b/lib/notification-api.ts @@ -35,6 +35,15 @@ export async function deletePushSubscription(endpoint: string) { if (!response.ok) throw new Error("Unable to delete push subscription."); } +export async function sendTestPushNotification(endpoint: string) { + const response = await fetch("/api/notifications/test", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ endpoint }), + }); + if (!response.ok) throw new Error("Unable to send test notification."); +} + export function decodeBase64UrlKey(value: string) { const padding = "=".repeat((4 - (value.length % 4)) % 4); const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/"); diff --git a/lib/push-service.ts b/lib/push-service.ts index a705f9f..fb2f28a 100644 --- a/lib/push-service.ts +++ b/lib/push-service.ts @@ -69,3 +69,16 @@ export async function sendWarningNotification(preference: WarningPushSubscriptio const payload = JSON.stringify(buildWarningPayload(preference, warning)); await webPush.sendNotification(preference.subscription as PushSubscription, payload); } + +export async function sendTestNotification(preference: WarningPushSubscription) { + ensureWebPushConfigured(); + const province = formatProvinceName(preference.province, preference.language); + const payload = JSON.stringify({ + title: preference.language === "pl" ? "wtr.: test powiadomień IMGW" : "wtr.: IMGW notification test", + body: preference.language === "pl" + ? `Powiadomienia są aktywne dla województwa: ${province}.` + : `Notifications are active for: ${province}.`, + url: "/settings", + }); + await webPush.sendNotification(preference.subscription as PushSubscription, payload); +} diff --git a/lib/push-store.ts b/lib/push-store.ts index e66f380..72a3530 100644 --- a/lib/push-store.ts +++ b/lib/push-store.ts @@ -31,6 +31,10 @@ export function getPushSubscriptions() { return Array.from(getStore().subscriptions.values()); } +export function getPushSubscription(endpoint: string) { + return getStore().subscriptions.get(endpoint) ?? null; +} + export function hasSentWarning(endpoint: string, warningId: string) { return getStore().sentWarningIds.get(endpoint)?.has(warningId) ?? false; }