Add test push notification flow
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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ą.
|
- 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.
|
- Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states.
|
||||||
|
|||||||
@@ -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.
|
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:
|
Do wysyłki Web Push wymagane są zmienne środowiskowe:
|
||||||
|
|
||||||
|
|||||||
30
app/api/notifications/test/route.ts
Normal file
30
app/api/notifications/test/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { LanguageToggle } from "@/components/ui/language-toggle";
|
|||||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||||
import { DEFAULT_STATION_ID } from "@/lib/constants";
|
import { DEFAULT_STATION_ID } from "@/lib/constants";
|
||||||
import { useI18n } from "@/lib/i18n";
|
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 { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces";
|
||||||
import { useWeatherStore } from "@/lib/store";
|
import { useWeatherStore } from "@/lib/store";
|
||||||
import type { Province } from "@/types/province";
|
import type { Province } from "@/types/province";
|
||||||
@@ -50,6 +50,7 @@ export function SettingsPage() {
|
|||||||
const [notificationStatus, setNotificationStatus] = useState<NotificationSupportStatus>("checking");
|
const [notificationStatus, setNotificationStatus] = useState<NotificationSupportStatus>("checking");
|
||||||
const [vapidPublicKey, setVapidPublicKey] = useState<string | null>(null);
|
const [vapidPublicKey, setVapidPublicKey] = useState<string | null>(null);
|
||||||
const [isSavingSubscription, setIsSavingSubscription] = useState(false);
|
const [isSavingSubscription, setIsSavingSubscription] = useState(false);
|
||||||
|
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
|
||||||
const [notificationMessage, setNotificationMessage] = useState<string | null>(null);
|
const [notificationMessage, setNotificationMessage] = useState<string | null>(null);
|
||||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section>
|
<section>
|
||||||
@@ -285,6 +302,10 @@ export function SettingsPage() {
|
|||||||
? t("settings.notifications.disable")
|
? t("settings.notifications.disable")
|
||||||
: t("settings.notifications.enableDevice")}
|
: t("settings.notifications.enableDevice")}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p className="mt-4 text-xs leading-5 text-muted">{t("settings.notifications.implementationNote")}</p>
|
<p className="mt-4 text-xs leading-5 text-muted">{t("settings.notifications.implementationNote")}</p>
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ const translations = {
|
|||||||
"settings.notifications.saveSuccess": "Powiadomienia są włączone dla tego urządzenia.",
|
"settings.notifications.saveSuccess": "Powiadomienia są włączone dla tego urządzenia.",
|
||||||
"settings.notifications.disableSuccess": "Powiadomienia są wyłą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.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.requestPermission": "Sprawdź zgodę",
|
||||||
"settings.notifications.statusChecking": "Sprawdzam obsługę powiadomień w tej przeglądarce.",
|
"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.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.saveSuccess": "Notifications are enabled for this device.",
|
||||||
"settings.notifications.disableSuccess": "Notifications are disabled for this device.",
|
"settings.notifications.disableSuccess": "Notifications are disabled for this device.",
|
||||||
"settings.notifications.saveError": "Unable to save the notification subscription.",
|
"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.requestPermission": "Check permission",
|
||||||
"settings.notifications.statusChecking": "Checking notification support in this browser.",
|
"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.statusUnconfigured": "The server does not have Web Push VAPID keys configured yet.",
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ export async function deletePushSubscription(endpoint: string) {
|
|||||||
if (!response.ok) throw new Error("Unable to delete push subscription.");
|
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) {
|
export function decodeBase64UrlKey(value: string) {
|
||||||
const padding = "=".repeat((4 - (value.length % 4)) % 4);
|
const padding = "=".repeat((4 - (value.length % 4)) % 4);
|
||||||
const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/");
|
const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
|||||||
@@ -69,3 +69,16 @@ export async function sendWarningNotification(preference: WarningPushSubscriptio
|
|||||||
const payload = JSON.stringify(buildWarningPayload(preference, warning));
|
const payload = JSON.stringify(buildWarningPayload(preference, warning));
|
||||||
await webPush.sendNotification(preference.subscription as PushSubscription, payload);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export function getPushSubscriptions() {
|
|||||||
return Array.from(getStore().subscriptions.values());
|
return Array.from(getStore().subscriptions.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPushSubscription(endpoint: string) {
|
||||||
|
return getStore().subscriptions.get(endpoint) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function hasSentWarning(endpoint: string, warningId: string) {
|
export function hasSentWarning(endpoint: string, warningId: string) {
|
||||||
return getStore().sentWarningIds.get(endpoint)?.has(warningId) ?? false;
|
return getStore().sentWarningIds.get(endpoint)?.has(warningId) ?? false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user