Add deterministic daily weather brief
This commit is contained in:
@@ -1,62 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { mergeForecastSources } from "@/lib/forecast-merge";
|
||||
import type { RawImgwForecastResponse, RawWeatherForecast } from "@/types/forecast";
|
||||
|
||||
const OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
|
||||
const IMGW_FORECAST_URL = "https://meteo.imgw.pl/api/v1/forecast/fcapi";
|
||||
// This browser token is published by the official meteo.imgw.pl frontend.
|
||||
const IMGW_FORECAST_TOKEN = "p4DXKjsYadfBV21TYrDk";
|
||||
const OPEN_METEO_TIMEOUT_MS = 12_000;
|
||||
const IMGW_TIMEOUT_MS = 5_000;
|
||||
|
||||
function parseCoordinate(value: string | null, min: number, max: number) {
|
||||
if (!value?.trim()) return null;
|
||||
const coordinate = Number(value);
|
||||
return Number.isFinite(coordinate) && coordinate >= min && coordinate <= max ? coordinate : null;
|
||||
}
|
||||
|
||||
async function readImgwPayload(response: Response | null) {
|
||||
if (!response?.ok) return null;
|
||||
try {
|
||||
return await response.json() as RawImgwForecastResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
import { fetchServerForecast, parseForecastCoordinate } from "@/lib/server-forecast";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const latitude = parseCoordinate(searchParams.get("latitude"), -90, 90);
|
||||
const longitude = parseCoordinate(searchParams.get("longitude"), -180, 180);
|
||||
const latitude = parseForecastCoordinate(searchParams.get("latitude"), -90, 90);
|
||||
const longitude = parseForecastCoordinate(searchParams.get("longitude"), -180, 180);
|
||||
if (latitude === null || longitude === null) {
|
||||
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
|
||||
}
|
||||
|
||||
const openMeteoParams = new URLSearchParams({
|
||||
latitude: String(latitude),
|
||||
longitude: String(longitude),
|
||||
hourly: "temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m",
|
||||
daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,sunrise,sunset",
|
||||
timezone: "Europe/Warsaw",
|
||||
forecast_days: "7",
|
||||
wind_speed_unit: "ms",
|
||||
});
|
||||
const imgwParams = new URLSearchParams({
|
||||
token: IMGW_FORECAST_TOKEN,
|
||||
lat: String(latitude),
|
||||
lon: String(longitude),
|
||||
m: "alaro",
|
||||
});
|
||||
|
||||
try {
|
||||
const [openMeteoResponse, imgwResponse] = await Promise.all([
|
||||
fetch(`${OPEN_METEO_FORECAST_URL}?${openMeteoParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(OPEN_METEO_TIMEOUT_MS) }),
|
||||
fetch(`${IMGW_FORECAST_URL}?${imgwParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(IMGW_TIMEOUT_MS) }).catch(() => null),
|
||||
]);
|
||||
if (!openMeteoResponse.ok) return NextResponse.json({ error: "Forecast service is unavailable." }, { status: 502 });
|
||||
const openMeteoPayload = await openMeteoResponse.json() as RawWeatherForecast;
|
||||
const imgwPayload = await readImgwPayload(imgwResponse);
|
||||
return NextResponse.json(mergeForecastSources(openMeteoPayload, imgwPayload), {
|
||||
return NextResponse.json(await fetchServerForecast(latitude, longitude), {
|
||||
headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" },
|
||||
});
|
||||
} catch {
|
||||
|
||||
88
app/api/notifications/daily-brief/route.ts
Normal file
88
app/api/notifications/daily-brief/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
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";
|
||||
|
||||
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()) {
|
||||
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 ?? "";
|
||||
return `${part("year")}-${part("month")}-${part("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 dateKey = getWarsawDateKey(now);
|
||||
const subscriptions = getPushSubscriptions().filter((subscription) => (
|
||||
subscription.enabled
|
||||
&& subscription.morningBriefEnabled
|
||||
&& Number.isFinite(subscription.latitude)
|
||||
&& Number.isFinite(subscription.longitude)
|
||||
));
|
||||
const warnings = await fetchMeteoWarnings(AbortSignal.timeout(12_000));
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
if (hasSentMorningBrief(subscription.endpoint, dateKey)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number);
|
||||
const brief = buildWeatherBrief({
|
||||
forecast,
|
||||
warnings,
|
||||
province: subscription.province,
|
||||
locationName: subscription.locationName ?? "wtr.",
|
||||
language: subscription.language,
|
||||
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,
|
||||
date: dateKey,
|
||||
subscriptions: subscriptions.length,
|
||||
sent,
|
||||
skipped,
|
||||
failed,
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,10 @@ export async function POST(request: Request) {
|
||||
province?: unknown;
|
||||
language?: unknown;
|
||||
enabled?: unknown;
|
||||
morningBriefEnabled?: unknown;
|
||||
latitude?: unknown;
|
||||
longitude?: unknown;
|
||||
locationName?: unknown;
|
||||
} | null;
|
||||
const subscription = body?.subscription;
|
||||
const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null);
|
||||
@@ -42,6 +46,10 @@ export async function POST(request: Request) {
|
||||
province,
|
||||
language,
|
||||
enabled: body?.enabled !== false,
|
||||
morningBriefEnabled: body?.morningBriefEnabled === true,
|
||||
latitude: typeof body?.latitude === "number" && Number.isFinite(body.latitude) ? body.latitude : null,
|
||||
longitude: typeof body?.longitude === "number" && Number.isFinite(body.longitude) ? body.longitude : null,
|
||||
locationName: typeof body?.locationName === "string" && body.locationName.trim() ? body.locationName.trim().slice(0, 80) : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user