Add Web Push warning notification backend
This commit is contained in:
22
lib/i18n.tsx
22
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",
|
||||
|
||||
43
lib/notification-api.ts
Normal file
43
lib/notification-api.ts
Normal file
@@ -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<VapidKeyResponse>;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
71
lib/push-service.ts
Normal file
71
lib/push-service.ts
Normal file
@@ -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);
|
||||
}
|
||||
51
lib/push-store.ts
Normal file
51
lib/push-store.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { WarningPushSubscription } from "@/types/notifications";
|
||||
|
||||
interface PushStore {
|
||||
subscriptions: Map<string, WarningPushSubscription>;
|
||||
sentWarningIds: Map<string, Set<string>>;
|
||||
}
|
||||
|
||||
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<string>();
|
||||
sentIds.add(warningId);
|
||||
store.sentWarningIds.set(endpoint, sentIds);
|
||||
}
|
||||
|
||||
export function pruneSentWarnings(activeWarningIds: Set<string>) {
|
||||
getStore().sentWarningIds.forEach((sentIds) => {
|
||||
sentIds.forEach((warningId) => {
|
||||
if (!activeWarningIds.has(warningId)) sentIds.delete(warningId);
|
||||
});
|
||||
});
|
||||
}
|
||||
15
lib/server-warnings.ts
Normal file
15
lib/server-warnings.ts
Normal file
@@ -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<WeatherWarning[]> {
|
||||
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)) : [];
|
||||
}
|
||||
Reference in New Issue
Block a user