141 lines
5.0 KiB
TypeScript
141 lines
5.0 KiB
TypeScript
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";
|
|
import { 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_TOMORROW_BRIEF_TIME = "18: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(), dayOffset = 0) {
|
|
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 ?? "";
|
|
const dateKey = `${part("year")}-${part("month")}-${part("day")}`;
|
|
const time = `${part("hour")}:${part("minute")}`;
|
|
if (dayOffset === 0) return { dateKey, time };
|
|
|
|
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: 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 { dateKey: `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`, time };
|
|
}
|
|
|
|
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_TOMORROW_BRIEF_TIME, DEFAULT_TOMORROW_BRIEF_TIME);
|
|
const subscriptions = getPushSubscriptions().filter(
|
|
(subscription) =>
|
|
subscription.tomorrowBriefEnabled &&
|
|
Number.isFinite(subscription.latitude) &&
|
|
Number.isFinite(subscription.longitude),
|
|
);
|
|
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 localToday = getLocalDateParts(subscription.timezone, now);
|
|
if (localToday.time < briefTime) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
const targetDateKey = getLocalDateParts(subscription.timezone, now, 1).dateKey;
|
|
if (hasSentTomorrowBrief(subscription.endpoint, targetDateKey)) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const forecast = await fetchServerForecast(
|
|
subscription.latitude as number,
|
|
subscription.longitude as number,
|
|
subscription.region,
|
|
);
|
|
const brief = buildTomorrowWeatherBrief({
|
|
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,
|
|
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,
|
|
time: briefTime,
|
|
subscriptions: subscriptions.length,
|
|
warningsUnavailable,
|
|
sent,
|
|
skipped,
|
|
failed,
|
|
});
|
|
}
|