feat: add tomorrow weather brief notifications

This commit is contained in:
zv
2026-06-12 22:10:22 +02:00
parent 3309e03acb
commit 7c3706c3f6
15 changed files with 414 additions and 15 deletions

View File

@@ -0,0 +1,104 @@
import { NextResponse } from "next/server";
import { fetchServerForecast } from "@/lib/server-forecast";
import { fetchMeteoWarnings } from "@/lib/server-warnings";
import { getPushSubscriptions, hasSentTomorrowBrief, markTomorrowBriefSent, removePushSubscription } from "@/lib/push-store";
import { isWebPushConfigured, sendTomorrowBriefNotification } from "@/lib/push-service";
import { buildTomorrowWeatherBrief } from "@/lib/weather-brief";
export const runtime = "nodejs";
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;
}
function getWarsawDateKey(date = new Date(), dayOffset = 0) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
if (dayOffset === 0) return `${part("year")}-${part("month")}-${part("day")}`;
const shiftedDate = new Date(Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12));
const shiftedParts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(shiftedDate);
const shiftedPart = (type: Intl.DateTimeFormatPartTypes) => shiftedParts.find((entry) => entry.type === type)?.value ?? "";
return `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`;
}
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 = new Date();
const targetDateKey = getWarsawDateKey(now, 1);
const subscriptions = getPushSubscriptions().filter((subscription) => (
subscription.enabled
&& subscription.tomorrowBriefEnabled
&& Number.isFinite(subscription.latitude)
&& Number.isFinite(subscription.longitude)
));
let warningsUnavailable = false;
const warnings = await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
warningsUnavailable = true;
return [];
});
let sent = 0;
let skipped = 0;
let failed = 0;
for (const subscription of subscriptions) {
if (hasSentTomorrowBrief(subscription.endpoint, targetDateKey)) {
skipped += 1;
continue;
}
try {
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number);
const brief = buildTomorrowWeatherBrief({
forecast,
warnings,
province: subscription.province,
countyTeryt: subscription.countyTeryt,
locationName: subscription.locationName ?? "wtr.",
language: subscription.language,
now,
});
if (!brief) {
skipped += 1;
continue;
}
await sendTomorrowBriefNotification(subscription, brief);
markTomorrowBriefSent(subscription.endpoint, targetDateKey);
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,
date: targetDateKey,
subscriptions: subscriptions.length,
warningsUnavailable,
sent,
skipped,
failed,
});
}