Files
zv 91acdb39b8
Some checks failed
CI / Lint, test, typecheck and build (push) Has been cancelled
feat: add weather unit preferences
2026-07-04 20:16:11 +02:00

147 lines
5.1 KiB
TypeScript

import { NextResponse } from "next/server";
import { fetchServerForecast } from "@/lib/server-forecast";
import { fetchMeteoWarnings } from "@/lib/server-warnings";
import {
getPushSubscriptions,
hasSentMorningBrief,
markMorningBriefSent,
removePushSubscription,
} from "@/lib/push-store";
import { isWebPushConfigured, sendMorningBriefNotification } from "@/lib/push-service";
import { buildWeatherBrief } from "@/lib/weather-brief";
import { DEFAULT_PRECIPITATION_UNIT, DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
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;
}
const DEFAULT_MORNING_BRIEF_TIME = "07:00";
function normalizeTime(value: string | undefined, fallback: string) {
return value && /^\d{2}:\d{2}$/.test(value) ? value : fallback;
}
function getLocalDateParts(timeZone: string | null, date = new Date()) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: timeZone || "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
return {
dateKey: `${part("year")}-${part("month")}-${part("day")}`,
time: `${part("hour")}:${part("minute")}`,
};
}
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 briefTime = normalizeTime(process.env.NOTIFICATIONS_MORNING_BRIEF_TIME, DEFAULT_MORNING_BRIEF_TIME);
const allSubscriptions = getPushSubscriptions();
const subscriptions = allSubscriptions.filter(
(subscription) =>
subscription.morningBriefEnabled &&
Number.isFinite(subscription.latitude) &&
Number.isFinite(subscription.longitude),
);
const skippedReasons = {
disabled: allSubscriptions.filter((subscription) => !subscription.morningBriefEnabled).length,
missingLocation: allSubscriptions.filter(
(subscription) =>
subscription.morningBriefEnabled &&
(!Number.isFinite(subscription.latitude) || !Number.isFinite(subscription.longitude)),
).length,
beforeTime: 0,
alreadySent: 0,
noBrief: 0,
};
let warningsUnavailable = false;
const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL");
const warnings = hasPolishSubscriptions
? await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
warningsUnavailable = true;
return [];
})
: [];
let sent = 0;
let skipped = 0;
let failed = 0;
for (const subscription of subscriptions) {
const localTime = getLocalDateParts(subscription.timezone, now);
if (localTime.time < briefTime) {
skipped += 1;
skippedReasons.beforeTime += 1;
continue;
}
const dateKey = localTime.dateKey;
if (hasSentMorningBrief(subscription.endpoint, dateKey)) {
skipped += 1;
skippedReasons.alreadySent += 1;
continue;
}
try {
const forecast = await fetchServerForecast(
subscription.latitude as number,
subscription.longitude as number,
subscription.region,
);
const brief = buildWeatherBrief({
forecast,
warnings: subscription.region === "PL" ? warnings : [],
province: subscription.province,
countyTeryt: subscription.region === "PL" ? subscription.countyTeryt : null,
locationName: subscription.locationName ?? "wtr.",
language: subscription.language,
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT,
precipitationUnit: subscription.precipitationUnit ?? DEFAULT_PRECIPITATION_UNIT,
now,
});
if (!brief) {
skipped += 1;
skippedReasons.noBrief += 1;
continue;
}
await sendMorningBriefNotification(subscription, brief);
markMorningBriefSent(subscription.endpoint, dateKey);
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,
time: briefTime,
subscriptions: allSubscriptions.length,
eligibleSubscriptions: subscriptions.length,
warningsUnavailable,
sent,
skipped,
skippedReasons,
failed,
});
}