Add test push notification flow

This commit is contained in:
zv
2026-06-11 19:36:23 +02:00
parent 054d75b1f4
commit b945fd4893
8 changed files with 88 additions and 3 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -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 });
}
}

View File

@@ -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<NotificationSupportStatus>("checking");
const [vapidPublicKey, setVapidPublicKey] = useState<string | null>(null);
const [isSavingSubscription, setIsSavingSubscription] = useState(false);
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
const [notificationMessage, setNotificationMessage] = useState<string | null>(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 (
<div className="space-y-6">
<section>
@@ -285,6 +302,10 @@ export function SettingsPage() {
? t("settings.notifications.disable")
: t("settings.notifications.enableDevice")}
</Button>
<Button type="button" variant="glass" disabled={!notificationsEnabled || isSavingSubscription || isSendingTestNotification} onClick={sendTestNotification}>
<BellRing className="size-4" />
{isSendingTestNotification ? t("settings.notifications.sendingTest") : t("settings.notifications.sendTest")}
</Button>
</div>
<p className="mt-4 text-xs leading-5 text-muted">{t("settings.notifications.implementationNote")}</p>

View File

@@ -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.",

View File

@@ -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, "/");

View File

@@ -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);
}

View File

@@ -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;
}