Add Web Push warning notification backend
This commit is contained in:
74
app/api/notifications/check/route.ts
Normal file
74
app/api/notifications/check/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchMeteoWarnings } from "@/lib/server-warnings";
|
||||
import { getPushSubscriptions, hasSentWarning, markWarningSent, pruneSentWarnings, removePushSubscription } from "@/lib/push-store";
|
||||
import { isWebPushConfigured, sendWarningNotification } from "@/lib/push-service";
|
||||
import type { WeatherWarning } from "@/types/imgw";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function getTimestamp(value: string | null) {
|
||||
if (!value) return null;
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
function isRelevantWarning(warning: WeatherWarning, now: number) {
|
||||
const validTo = getTimestamp(warning.validTo);
|
||||
return warning.kind === "meteo" && (validTo === null || validTo > now);
|
||||
}
|
||||
|
||||
function isCronAuthorized(request: Request) {
|
||||
const secret = process.env.NOTIFICATIONS_CRON_SECRET;
|
||||
if (!secret) return process.env.NODE_ENV !== "production";
|
||||
const authHeader = request.headers.get("authorization");
|
||||
const secretHeader = request.headers.get("x-cron-secret");
|
||||
return authHeader === `Bearer ${secret}` || secretHeader === secret;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!isCronAuthorized(request)) {
|
||||
return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
|
||||
}
|
||||
if (!isWebPushConfigured()) {
|
||||
return NextResponse.json({ error: "Web Push is not configured." }, { status: 503 });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const warnings = (await fetchMeteoWarnings(AbortSignal.timeout(12_000))).filter((warning) => isRelevantWarning(warning, now));
|
||||
const subscriptions = getPushSubscriptions().filter((subscription) => subscription.enabled);
|
||||
const activeWarningIds = new Set(warnings.map((warning) => warning.id));
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
pruneSentWarnings(activeWarningIds);
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const matchingWarnings = warnings.filter((warning) => warning.provinces.includes(subscription.province));
|
||||
for (const warning of matchingWarnings) {
|
||||
if (hasSentWarning(subscription.endpoint, warning.id)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendWarningNotification(subscription, warning);
|
||||
markWarningSent(subscription.endpoint, warning.id);
|
||||
sent += 1;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
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({
|
||||
ok: true,
|
||||
subscriptions: subscriptions.length,
|
||||
warnings: warnings.length,
|
||||
sent,
|
||||
skipped,
|
||||
failed,
|
||||
});
|
||||
}
|
||||
59
app/api/notifications/subscriptions/route.ts
Normal file
59
app/api/notifications/subscriptions/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { upsertPushSubscription, removePushSubscription } from "@/lib/push-store";
|
||||
import { isWebPushConfigured } from "@/lib/push-service";
|
||||
import { normalizeProvinceName } from "@/lib/provinces";
|
||||
import type { Language } from "@/lib/i18n";
|
||||
import type { BrowserPushSubscription } from "@/types/notifications";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function isBrowserPushSubscription(value: unknown): value is BrowserPushSubscription {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const subscription = value as Partial<BrowserPushSubscription>;
|
||||
return typeof subscription.endpoint === "string"
|
||||
&& subscription.endpoint.startsWith("https://")
|
||||
&& typeof subscription.keys?.p256dh === "string"
|
||||
&& typeof subscription.keys.auth === "string";
|
||||
}
|
||||
|
||||
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 {
|
||||
subscription?: unknown;
|
||||
province?: unknown;
|
||||
language?: unknown;
|
||||
enabled?: unknown;
|
||||
} | null;
|
||||
const subscription = body?.subscription;
|
||||
const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null);
|
||||
const language: Language = body?.language === "en" ? "en" : "pl";
|
||||
|
||||
if (!isBrowserPushSubscription(subscription) || !province) {
|
||||
return NextResponse.json({ error: "Invalid push subscription." }, { status: 400 });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
upsertPushSubscription({
|
||||
endpoint: subscription.endpoint,
|
||||
subscription,
|
||||
province,
|
||||
language,
|
||||
enabled: body?.enabled !== false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
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 });
|
||||
}
|
||||
removePushSubscription(body.endpoint);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
11
app/api/notifications/vapid-key/route.ts
Normal file
11
app/api/notifications/vapid-key/route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getVapidPublicKey, isWebPushConfigured } from "@/lib/push-service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({
|
||||
configured: isWebPushConfigured(),
|
||||
publicKey: getVapidPublicKey(),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user