137 lines
4.8 KiB
JavaScript
137 lines
4.8 KiB
JavaScript
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);
|