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:
1 parent
3a53a86ce0
commit
3255314402
16 files changed
+1272
-23
No files matched your search
@@ -0,0 +1,125 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { settings } from "../db/schema.js";
|
||||
|
||||
export interface GotifySettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
token: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface NtfySettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
topic: string;
|
||||
token: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface SmtpSettings {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface WebhookSettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export interface NotificationEvents {
|
||||
dnsAdd: boolean;
|
||||
dnsUpdate: boolean;
|
||||
dnsDelete: boolean;
|
||||
secretCheck: boolean;
|
||||
secretCheckTime: string; // "HH:MM"
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export type ProviderColors = Record<string, string>;
|
||||
|
||||
export interface AppSettings {
|
||||
gotify: GotifySettings;
|
||||
ntfy: NtfySettings;
|
||||
smtp: SmtpSettings;
|
||||
webhook: WebhookSettings;
|
||||
notifications: NotificationEvents;
|
||||
providerColors: ProviderColors;
|
||||
}
|
||||
|
||||
const DEFAULTS: AppSettings = {
|
||||
gotify: { enabled: false, url: "", token: "", priority: 5 },
|
||||
ntfy: { enabled: false, url: "https://ntfy.sh", topic: "", token: "", priority: 3 },
|
||||
smtp: { enabled: false, host: "", port: 587, secure: false, username: "", password: "", from: "", to: "" },
|
||||
webhook: { enabled: false, url: "", secret: "" },
|
||||
notifications: {
|
||||
dnsAdd: true,
|
||||
dnsUpdate: true,
|
||||
dnsDelete: true,
|
||||
secretCheck: true,
|
||||
secretCheckTime: "08:00",
|
||||
timezone: "UTC",
|
||||
},
|
||||
providerColors: {},
|
||||
};
|
||||
|
||||
const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[];
|
||||
|
||||
export async function getSettings(): Promise<AppSettings> {
|
||||
const rows = await db.select().from(settings);
|
||||
const byKey = new Map(rows.map((r) => [r.key, r.value]));
|
||||
|
||||
const result = {} as AppSettings;
|
||||
for (const key of KEYS) {
|
||||
const raw = byKey.get(key);
|
||||
let parsed: Record<string, unknown> = {};
|
||||
if (raw) {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
}
|
||||
(result[key] as Record<string, unknown>) = { ...(DEFAULTS[key] as object), ...parsed };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial<AppSettings[K]> };
|
||||
|
||||
export async function updateSettings(partial: AppSettingsPatch): Promise<AppSettings> {
|
||||
const current = await getSettings();
|
||||
|
||||
for (const key of Object.keys(partial) as (keyof AppSettings)[]) {
|
||||
if (!KEYS.includes(key)) continue;
|
||||
const merged = { ...(current[key] as object), ...(partial[key] as object) };
|
||||
const value = JSON.stringify(merged);
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ key, value })
|
||||
.onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: sql`(current_timestamp)` } });
|
||||
}
|
||||
|
||||
return getSettings();
|
||||
}
|
||||
|
||||
/** Small internal key/value slot for scheduler bookkeeping, outside the AppSettings shape. */
|
||||
export async function getInternalFlag(key: string): Promise<string | null> {
|
||||
const [row] = await db.select().from(settings).where(sql`${settings.key} = ${`_internal:${key}`}`).limit(1);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export async function setInternalFlag(key: string, value: string): Promise<void> {
|
||||
const fullKey = `_internal:${key}`;
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ key: fullKey, value })
|
||||
.onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: sql`(current_timestamp)` } });
|
||||
}
|
||||
Reference in new issue
Block a user