import { existsSync, readFileSync } from "node:fs"; const DEFAULT_APP_URL = "http://127.0.0.1:3000"; const DEFAULT_WARNING_INTERVAL_MINUTES = 5; const DEFAULT_MORNING_BRIEF_TIME = "07:00"; const DEFAULT_TOMORROW_BRIEF_TIME = "18:00"; const LOOP_INTERVAL_MS = 30_000; const initialEnvKeys = new Set(Object.keys(process.env)); loadEnvFile(".env", false); loadEnvFile(".env.local", true); const appUrl = normalizeAppUrl(process.env.WTR_APP_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? DEFAULT_APP_URL); const cronSecret = process.env.NOTIFICATIONS_CRON_SECRET ?? ""; const warningIntervalMinutes = readPositiveNumber(process.env.NOTIFICATIONS_WARNING_INTERVAL_MINUTES, DEFAULT_WARNING_INTERVAL_MINUTES); const morningBriefTime = normalizeTime(process.env.NOTIFICATIONS_MORNING_BRIEF_TIME ?? DEFAULT_MORNING_BRIEF_TIME, DEFAULT_MORNING_BRIEF_TIME); const tomorrowBriefTime = normalizeTime(process.env.NOTIFICATIONS_TOMORROW_BRIEF_TIME ?? DEFAULT_TOMORROW_BRIEF_TIME, DEFAULT_TOMORROW_BRIEF_TIME); let lastWarningCheckAt = 0; let lastMorningBriefDate = ""; let lastTomorrowBriefDate = ""; let isRunningTick = false; function loadEnvFile(path, overrideLoadedValues) { if (!existsSync(path)) return; const lines = readFileSync(path, "utf8").split(/\r?\n/); for (const line of lines) { const trimmedLine = line.trim(); if (!trimmedLine || trimmedLine.startsWith("#")) continue; const separatorIndex = trimmedLine.indexOf("="); if (separatorIndex === -1) continue; const key = trimmedLine.slice(0, separatorIndex).trim(); const rawValue = trimmedLine.slice(separatorIndex + 1).trim(); if (!key || initialEnvKeys.has(key)) continue; if (!overrideLoadedValues && process.env[key] !== undefined) continue; process.env[key] = rawValue.replace(/^['"]|['"]$/g, ""); } } function normalizeAppUrl(value) { return value.replace(/\/+$/, ""); } function readPositiveNumber(value, fallback) { const number = Number(value); return Number.isFinite(number) && number > 0 ? number : fallback; } function normalizeTime(value, fallback) { return /^\d{2}:\d{2}$/.test(value) ? value : fallback; } function getWarsawDateParts(date = new Date()) { const parts = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Warsaw", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }).formatToParts(date); const part = (type) => parts.find((entry) => entry.type === type)?.value ?? ""; return { dateKey: `${part("year")}-${part("month")}-${part("day")}`, time: `${part("hour")}:${part("minute")}`, }; } function isAtOrAfterTime(currentTime, targetTime) { return currentTime >= targetTime; } async function callNotificationEndpoint(path) { const response = await fetch(`${appUrl}${path}`, { headers: cronSecret ? { authorization: `Bearer ${cronSecret}` } : {}, }); const body = await response.text(); if (!response.ok) throw new Error(`${path} returned ${response.status}: ${body}`); return body; } async function callNotificationEndpointSafely(path) { try { await callNotificationEndpoint(path); return true; } catch (error) { console.error(formatWorkerError(error)); return false; } } function formatWorkerError(error) { if (error?.cause?.code === "ECONNREFUSED") { return [ `wtr. notification worker: app is not reachable at ${appUrl}.`, "Start the Next.js app first with `npm run start`, or set WTR_APP_URL to the running app URL.", ].join(" "); } if (error instanceof Error) return error.message; return String(error); } async function tick() { if (isRunningTick) return; isRunningTick = true; const now = Date.now(); const warningIntervalMs = warningIntervalMinutes * 60_000; try { if (now - lastWarningCheckAt >= warningIntervalMs) { lastWarningCheckAt = now; await callNotificationEndpointSafely("/api/notifications/check"); } const warsawTime = getWarsawDateParts(); if (isAtOrAfterTime(warsawTime.time, morningBriefTime) && lastMorningBriefDate !== warsawTime.dateKey) { const sentBrief = await callNotificationEndpointSafely("/api/notifications/daily-brief"); if (sentBrief) lastMorningBriefDate = warsawTime.dateKey; } if (isAtOrAfterTime(warsawTime.time, tomorrowBriefTime) && lastTomorrowBriefDate !== warsawTime.dateKey) { const sentBrief = await callNotificationEndpointSafely("/api/notifications/tomorrow-brief"); if (sentBrief) lastTomorrowBriefDate = warsawTime.dateKey; } } catch (error) { console.error(formatWorkerError(error)); } finally { isRunningTick = false; } } console.log(`wtr. notification worker: ${appUrl}`); console.log(`Warnings every ${warningIntervalMinutes} min, morning brief at ${morningBriefTime}, tomorrow brief at ${tomorrowBriefTime} Europe/Warsaw.`); void tick(); setInterval(tick, LOOP_INTERVAL_MS);