Add notification scheduler worker
This commit is contained in:
@@ -2,3 +2,6 @@ WEB_PUSH_VAPID_PUBLIC_KEY=
|
|||||||
WEB_PUSH_VAPID_PRIVATE_KEY=
|
WEB_PUSH_VAPID_PRIVATE_KEY=
|
||||||
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
|
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
|
||||||
NOTIFICATIONS_CRON_SECRET=
|
NOTIFICATIONS_CRON_SECRET=
|
||||||
|
WTR_APP_URL=http://127.0.0.1:3000
|
||||||
|
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
|
||||||
|
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ npm run dev
|
|||||||
npm run lint
|
npm run lint
|
||||||
npm run build
|
npm run build
|
||||||
npm run start
|
npm run start
|
||||||
|
npm run notifications:worker
|
||||||
```
|
```
|
||||||
|
|
||||||
Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani formattera. `npm run build` uruchamia kontrolę TypeScript wykonywaną przez Next.js. Nie opisuj ani nie uruchamiaj nieistniejących komend jako standardowego workflow.
|
Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani formattera. `npm run build` uruchamia kontrolę TypeScript wykonywaną przez Next.js. `npm run notifications:worker` uruchamia osobny proces Node do cyklicznego wywoływania endpointów Web Push na self-hostingu. Nie opisuj ani nie uruchamiaj nieistniejących komend jako standardowego workflow.
|
||||||
|
|
||||||
## Konwencje kodu
|
## Konwencje kodu
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for
|
|||||||
- 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.
|
||||||
- Brief dnia generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i ostrzeżeń IMGW. Nie traktuj go jako odpowiedzi modelu AI i nie wymagaj klucza OpenAI API.
|
- Brief dnia generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i ostrzeżeń IMGW. Nie traktuj go jako odpowiedzi modelu AI i nie wymagaj klucza OpenAI API.
|
||||||
- Powiadomienia Web Push o ostrzeżeniach meteo i porannym briefie konfiguruj przez `/settings`, `public/sw.js` i route handlery `app/api/notifications/*`, w tym testowy `/api/notifications/test`. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. 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 i porannym briefie konfiguruj przez `/settings`, `public/sw.js`, route handlery `app/api/notifications/*`, w tym testowy `/api/notifications/test`, oraz self-hostowany worker `scripts/notification-worker.mjs`. Endpointy harmonogramu nie uruchamiają się same bez workera albo zewnętrznego crona. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. 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.
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -43,6 +43,12 @@ npm run build
|
|||||||
npm run start
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Worker powiadomień uruchamiany obok aplikacji:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run notifications:worker
|
||||||
|
```
|
||||||
|
|
||||||
## Źródła danych
|
## Źródła danych
|
||||||
|
|
||||||
Bieżące pomiary i komunikaty pochodzą z rzeczywistych publicznych danych IMGW:
|
Bieżące pomiary i komunikaty pochodzą z rzeczywistych publicznych danych IMGW:
|
||||||
@@ -131,7 +137,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, zapis subskrypcji urządzenia, poranny brief i wysłanie powiadomienia testowego. Na iOS/iPadOS powiadomienia webowe wymagają dodania PWA do ekranu początkowego i uruchomienia aplikacji z ikony. Na Androidzie i desktopie wystarczy HTTPS oraz przeglądarka obsługująca Web Push; instalacja PWA nie jest wymagana. Endpoint `/api/notifications/check` sprawdza `warningsmeteo` i wysyła tylko nowe ostrzeżenia meteo do pasujących subskrypcji, `/api/notifications/daily-brief` wysyła raz dziennie krótkie podsumowanie dla subskrypcji z zapisaną lokalizacją, 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.
|
Widok `/settings` zawiera konfigurację powiadomień o ostrzeżeniach meteorologicznych IMGW: wybór województwa, zgodę Web Push, zapis subskrypcji urządzenia, poranny brief i wysłanie powiadomienia testowego. Na iOS/iPadOS powiadomienia webowe wymagają dodania PWA do ekranu początkowego i uruchomienia aplikacji z ikony. Na Androidzie i desktopie wystarczy HTTPS oraz przeglądarka obsługująca Web Push; instalacja PWA nie jest wymagana. Endpoint `/api/notifications/check` sprawdza `warningsmeteo` i wysyła tylko nowe ostrzeżenia meteo do pasujących subskrypcji, `/api/notifications/daily-brief` wysyła raz dziennie krótkie podsumowanie dla subskrypcji z zapisaną lokalizacją, a `/api/notifications/test` wysyła test na wskazany endpoint subskrypcji. Endpointy harmonogramu nie uruchamiają się same: na self-hostingu uruchom `npm run notifications:worker` obok `npm run start`, albo użyj zewnętrznego crona. 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:
|
||||||
|
|
||||||
@@ -140,9 +146,12 @@ WEB_PUSH_VAPID_PUBLIC_KEY=
|
|||||||
WEB_PUSH_VAPID_PRIVATE_KEY=
|
WEB_PUSH_VAPID_PRIVATE_KEY=
|
||||||
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
|
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
|
||||||
NOTIFICATIONS_CRON_SECRET=
|
NOTIFICATIONS_CRON_SECRET=
|
||||||
|
WTR_APP_URL=http://127.0.0.1:3000
|
||||||
|
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
|
||||||
|
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
|
||||||
```
|
```
|
||||||
|
|
||||||
Klucze VAPID można wygenerować poleceniem `npx web-push generate-vapid-keys`. Cron ostrzeżeń powinien wywoływać `GET /api/notifications/check` z nagłówkiem `Authorization: Bearer <NOTIFICATIONS_CRON_SECRET>` albo `x-cron-secret`. Drugi cron, ustawiony na około 07:00 czasu polskiego, powinien wywoływać `GET /api/notifications/daily-brief` z tym samym nagłówkiem.
|
Klucze VAPID można wygenerować poleceniem `npx web-push generate-vapid-keys`. Worker `npm run notifications:worker` domyślnie odpytuje `http://127.0.0.1:3000`, sprawdza ostrzeżenia co 5 minut i wysyła poranny brief po 07:00 czasu polskiego. Jeśli używasz zewnętrznego crona zamiast workera, cron ostrzeżeń powinien wywoływać `GET /api/notifications/check` z nagłówkiem `Authorization: Bearer <NOTIFICATIONS_CRON_SECRET>` albo `x-cron-secret`. Drugi cron, ustawiony na około 07:00 czasu polskiego, powinien wywoływać `GET /api/notifications/daily-brief` z tym samym nagłówkiem.
|
||||||
|
|
||||||
## Wdrożenie na Vercel
|
## Wdrożenie na Vercel
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint ."
|
"lint": "eslint .",
|
||||||
|
"notifications:worker": "node scripts/notification-worker.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.80.0",
|
"@tanstack/react-query": "^5.80.0",
|
||||||
|
|||||||
84
scripts/notification-worker.mjs
Normal file
84
scripts/notification-worker.mjs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
const DEFAULT_APP_URL = "http://127.0.0.1:3000";
|
||||||
|
const DEFAULT_WARNING_INTERVAL_MINUTES = 5;
|
||||||
|
const DEFAULT_MORNING_BRIEF_TIME = "07:00";
|
||||||
|
const LOOP_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
const appUrl = normalizeAppUrl(process.env.WTR_APP_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? DEFAULT_APP_URL);
|
||||||
|
const cronSecret = process.env.NOTIFICATIONS_CRON_SECRET ?? "";
|
||||||
|
const warningIntervalMinutes = readPositiveNumber(process.env.NOTIFICATIONS_WARNING_INTERVAL_MINUTES, DEFAULT_WARNING_INTERVAL_MINUTES);
|
||||||
|
const morningBriefTime = normalizeTime(process.env.NOTIFICATIONS_MORNING_BRIEF_TIME ?? DEFAULT_MORNING_BRIEF_TIME);
|
||||||
|
|
||||||
|
let lastWarningCheckAt = 0;
|
||||||
|
let lastMorningBriefDate = "";
|
||||||
|
let isRunningTick = false;
|
||||||
|
|
||||||
|
function normalizeAppUrl(value) {
|
||||||
|
return value.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPositiveNumber(value, fallback) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) && number > 0 ? number : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTime(value) {
|
||||||
|
return /^\d{2}:\d{2}$/.test(value) ? value : DEFAULT_MORNING_BRIEF_TIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWarsawDateParts(date = new Date()) {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: "Europe/Warsaw",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
}).formatToParts(date);
|
||||||
|
const part = (type) => parts.find((entry) => entry.type === type)?.value ?? "";
|
||||||
|
return {
|
||||||
|
dateKey: `${part("year")}-${part("month")}-${part("day")}`,
|
||||||
|
time: `${part("hour")}:${part("minute")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAtOrAfterTime(currentTime, targetTime) {
|
||||||
|
return currentTime >= targetTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callNotificationEndpoint(path) {
|
||||||
|
const response = await fetch(`${appUrl}${path}`, {
|
||||||
|
headers: cronSecret ? { authorization: `Bearer ${cronSecret}` } : {},
|
||||||
|
});
|
||||||
|
const body = await response.text();
|
||||||
|
if (!response.ok) throw new Error(`${path} returned ${response.status}: ${body}`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
if (isRunningTick) return;
|
||||||
|
isRunningTick = true;
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
const warningIntervalMs = warningIntervalMinutes * 60_000;
|
||||||
|
if (now - lastWarningCheckAt >= warningIntervalMs) {
|
||||||
|
lastWarningCheckAt = now;
|
||||||
|
await callNotificationEndpoint("/api/notifications/check");
|
||||||
|
}
|
||||||
|
|
||||||
|
const warsawTime = getWarsawDateParts();
|
||||||
|
if (isAtOrAfterTime(warsawTime.time, morningBriefTime) && lastMorningBriefDate !== warsawTime.dateKey) {
|
||||||
|
await callNotificationEndpoint("/api/notifications/daily-brief");
|
||||||
|
lastMorningBriefDate = warsawTime.dateKey;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
isRunningTick = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`wtr. notification worker: ${appUrl}`);
|
||||||
|
console.log(`Warnings every ${warningIntervalMinutes} min, morning brief at ${morningBriefTime} Europe/Warsaw.`);
|
||||||
|
void tick();
|
||||||
|
setInterval(tick, LOOP_INTERVAL_MS);
|
||||||
Reference in New Issue
Block a user