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_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 subscriptions = getPushSubscriptions().filter( (subscription) => subscription.morningBriefEnabled && 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 localTime = getLocalDateParts(subscription.timezone, now); if (localTime.time < briefTime) { skipped += 1; continue; } const dateKey = localTime.dateKey; if (hasSentMorningBrief(subscription.endpoint, dateKey)) { skipped += 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, now, }); if (!brief) { skipped += 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: subscriptions.length, warningsUnavailable, sent, skipped, failed, }); }