Files
Homelab-manager/server/src/services/settingsStore.ts
T
bobbanandClaude Sonnet 5 f92f8de96e Add a Date & Time setting and fix inconsistent date formats app-wide
Different tables used different date formats: Servers & Tasks used a
fixed "YYYY-MM-DD HH:mm:ss" (24h), while Audit Log, DNS, Integrations
(Tailscale/Semaphore), and Users called plain .toLocaleString() with
no options, which renders using the browser's own locale — different
per browser/OS, and inconsistent with the other pages' fixed format.

- New Settings -> Date & Time page: pick date order (YYYY-MM-DD,
  DD/MM/YYYY, MM/DD/YYYY) and 12h vs 24h clock, with a live preview.
- utils/date.ts's formatDateTime() now reads these settings instead of
  being hardcoded to sv-SE/24h; the setting is fetched once at app
  startup (alongside /api/me) via a new non-secret GET
  /api/settings/display (any signed-in user, same rationale as the
  badge-color endpoints) and applied immediately on save too, without
  needing a page reload.
- Switched every remaining raw new Date(...).toLocaleString() call
  (Audit Log, DNS zone sync time, Tailscale/Semaphore last-seen,
  Users' last login) over to the shared formatter, so every table now
  renders dates identically.

Verified the formatter's date-order x 12h logic against all six
combinations plus the midnight/noon 12h edge cases, and the settings
endpoints end-to-end (defaults, partial updates, validation
rejection, audit logging) against the real dev server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 22:17:06 +02:00

139 lines
3.7 KiB
TypeScript

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 type IntegrationColors = Record<string, string>;
export type DateFormat = "ymd" | "dmy" | "mdy";
export type TimeFormat = "24h" | "12h";
export interface DisplaySettings {
dateFormat: DateFormat;
timeFormat: TimeFormat;
}
export interface AppSettings {
gotify: GotifySettings;
ntfy: NtfySettings;
smtp: SmtpSettings;
webhook: WebhookSettings;
notifications: NotificationEvents;
providerColors: ProviderColors;
integrationColors: IntegrationColors;
display: DisplaySettings;
}
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: {},
integrationColors: {},
display: { dateFormat: "ymd", timeFormat: "24h" },
};
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)` } });
}