import { existsSync, readFileSync } from "node:fs"; const DEFAULT_APP_URL = "http://127.0.0.1:3000"; const DEFAULT_WARNING_INTERVAL_MINUTES = 5; const DEFAULT_BRIEF_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 briefIntervalMinutes = readPositiveNumber(process.env.NOTIFICATIONS_BRIEF_INTERVAL_MINUTES, DEFAULT_BRIEF_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 lastMorningBriefCheckAt = 0; let lastTomorrowBriefCheckAt = 0; 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; } 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; const briefIntervalMs = briefIntervalMinutes * 60_000; try { if (now - lastWarningCheckAt >= warningIntervalMs) { lastWarningCheckAt = now; await callNotificationEndpointSafely("/api/notifications/check"); } if (now - lastMorningBriefCheckAt >= briefIntervalMs) { lastMorningBriefCheckAt = now; await callNotificationEndpointSafely("/api/notifications/daily-brief"); } if (now - lastTomorrowBriefCheckAt >= briefIntervalMs) { lastTomorrowBriefCheckAt = now; await callNotificationEndpointSafely("/api/notifications/tomorrow-brief"); } } catch (error) { console.error(formatWorkerError(error)); } finally { isRunningTick = false; } } console.log(`wtr. notification worker: ${appUrl}`); console.log(`Warnings every ${warningIntervalMinutes} min. Brief checks every ${briefIntervalMinutes} min; targets: ${morningBriefTime} and ${tomorrowBriefTime} in each subscription timezone.`); void tick(); setInterval(tick, LOOP_INTERVAL_MS);