Build the Settings module: notification channels, event toggles, DNS badge colors

The Settings page was a "coming soon" placeholder. Port Sloth Manager's
settings feature set: Gotify/ntfy/SMTP/webhook notification channels
(each with its own test-send button), per-event toggles (DNS record
added/updated/deleted, a daily secret-expiry digest with configurable
time/timezone), and per-provider DNS badge color customization.

Settings persist in the existing `settings` key/value table via a new
settingsStore service; a notify service fans a message out to every
enabled channel. DNS record add/update/delete now fire notifications,
and a node-schedule job re-arms itself whenever the notification
settings change. Removed the now-superseded GOTIFY_URL/GOTIFY_TOKEN
env vars in favor of in-app configuration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 12:14:05 +02:00
1 parent 3a53a86ce0
commit 3255314402
16 files changed
+1272 -23

No files matched your search

@@ -0,0 +1,53 @@
import schedule from "node-schedule";
import { db } from "../db/client.js";
import { secrets } from "../db/schema.js";
import { computeSecretStatus } from "./secretStatus.js";
import { notifySecretExpiry } from "./notify.js";
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
const LAST_RUN_FLAG = "secretCheckLastRunDate";
async function checkSecretExpiry(): Promise<void> {
const rows = await db.select().from(secrets);
const expiring = rows
.map((s) => ({ name: s.name, ...computeSecretStatus(s.expiryDate, s.warnDays) }))
.filter((s): s is typeof s & { status: "expired" | "expiring" } => s.status === "expired" || s.status === "expiring");
await notifySecretExpiry(expiring);
}
async function checkSecretExpiryOnce(): Promise<void> {
const today = new Date().toDateString();
const lastRun = await getInternalFlag(LAST_RUN_FLAG);
if (lastRun === today) return;
await setInternalFlag(LAST_RUN_FLAG, today);
await checkSecretExpiry();
}
function cronFromTime(time: string): string {
const [h, m] = time.split(":").map(Number);
return `${Number.isFinite(m) ? m : 0} ${Number.isFinite(h) ? h : 8} * * *`;
}
let currentJob: schedule.Job | null = null;
/** (Re)schedules the daily secret-expiry check per the current notification settings. Call again after settings change. */
export async function scheduleSecretExpiryCheck(): Promise<void> {
if (currentJob) {
currentJob.cancel();
currentJob = null;
}
const { notifications } = await getSettings();
currentJob = schedule.scheduleJob({ rule: cronFromTime(notifications.secretCheckTime), tz: notifications.timezone }, () => {
setInternalFlag(LAST_RUN_FLAG, "").catch(() => {});
getSettings().then(({ notifications: n }) => {
if (n.secretCheck) checkSecretExpiry().catch((err) => console.error("[secretExpiry] check failed:", err));
});
});
console.log(`Secret expiry check scheduled at ${notifications.secretCheckTime} (${notifications.timezone})`);
}
/** Runs once at startup (skipped if already run today), then arms the daily schedule. */
export async function initSecretExpiryScheduler(): Promise<void> {
await checkSecretExpiryOnce();
await scheduleSecretExpiryCheck();
}