Files
medassist-ng/backend/src/services/settings-service.ts
T
2026-05-10 19:01:15 +02:00

361 lines
14 KiB
TypeScript

import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { userSettings } from "../db/schema.js";
import type { Language } from "../i18n/translations.js";
import { isNtfyNotificationUrl } from "./notifications/action-renderer.js";
export type UserSettings = {
userId: number;
timezone?: string | null;
emailEnabled: boolean;
notificationEmail: string | null;
emailStockReminders: boolean;
emailIntakeReminders: boolean;
emailPrescriptionReminders: boolean;
shoutrrrEnabled: boolean;
shoutrrrUrl: string | null;
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
shoutrrrPrescriptionReminders: boolean;
reminderDaysBefore: number;
repeatDailyReminders: boolean;
skipRemindersForTakenDoses: boolean;
repeatRemindersEnabled: boolean;
reminderRepeatIntervalMinutes: number;
maxNaggingReminders: number;
lowStockDays: number;
normalStockDays: number;
highStockDays: number;
language: Language;
stockCalculationMode: "automatic" | "manual";
shareMedicationOverview: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
swapDashboardMainSections: boolean;
lastAutoEmailSent: string | null;
lastNotificationType: string | null;
lastNotificationChannel: string | null;
lastReminderMedName: string | null;
lastReminderTakenBy: string | null;
lastStockReminderSent: string | null;
lastStockReminderChannel: string | null;
lastStockReminderMedNames: string | null;
lastPrescriptionReminderSent: string | null;
lastPrescriptionReminderChannel: string | null;
lastPrescriptionReminderMedNames: string | null;
};
export function classifyTestEmailFailure(error: unknown): { status: number; code: string; message: string } {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
const normalizedMessage = errorMessage.toLowerCase();
if (
normalizedMessage.includes("smtp rejected all recipients") ||
normalizedMessage.includes("all recipients were rejected") ||
normalizedMessage.includes("recipient address rejected") ||
normalizedMessage.includes("nullmx")
) {
return {
status: 400,
code: "EMAIL_RECIPIENT_REJECTED",
message: `Failed to send email: ${errorMessage}`,
};
}
if (errorMessage.includes("SMTP did not confirm accepted recipients")) {
return {
status: 502,
code: "SMTP_DELIVERY_UNCONFIRMED",
message: `Failed to send email: ${errorMessage}`,
};
}
return {
status: 500,
code: "TEST_EMAIL_FAILED",
message: `Failed to send email: ${errorMessage}`,
};
}
export function getNotificationProvider(url: string): string {
if (url.startsWith("discord://")) return "discord";
if (url.startsWith("telegram://")) return "telegram";
if (url.startsWith("gotify://")) return "gotify";
if (url.startsWith("pushover://")) return "pushover";
if (isNtfyNotificationUrl(url)) return "ntfy";
try {
const parsed = new URL(url);
return parsed.hostname || "https";
} catch {
return "unknown";
}
}
function envBool(key: string, defaultVal: boolean): boolean {
const val = process.env[key];
if (val === undefined) return defaultVal;
return val === "true" || val === "1";
}
function envInt(key: string, defaultVal: number): number {
const val = process.env[key];
if (val === undefined) return defaultVal;
const parsed = parseInt(val, 10);
return Number.isNaN(parsed) ? defaultVal : parsed;
}
export function getDefaultSettings() {
return {
timezone: "",
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
emailIntakeReminders: envBool("DEFAULT_EMAIL_INTAKE_REMINDERS", true),
emailPrescriptionReminders: envBool("DEFAULT_EMAIL_PRESCRIPTION_REMINDERS", true),
shoutrrrEnabled: envBool("DEFAULT_SHOUTRRR_ENABLED", false),
shoutrrrUrl: process.env.DEFAULT_SHOUTRRR_URL || null,
shoutrrrStockReminders: envBool("DEFAULT_SHOUTRRR_STOCK_REMINDERS", true),
shoutrrrIntakeReminders: envBool("DEFAULT_SHOUTRRR_INTAKE_REMINDERS", true),
shoutrrrPrescriptionReminders: envBool("DEFAULT_SHOUTRRR_PRESCRIPTION_REMINDERS", true),
reminderDaysBefore: envInt("REMINDER_DAYS_BEFORE", 7),
repeatDailyReminders: envBool("DEFAULT_REPEAT_DAILY_REMINDERS", false),
skipRemindersForTakenDoses: envBool("DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES", false),
repeatRemindersEnabled: envBool("DEFAULT_REPEAT_REMINDERS_ENABLED", false),
reminderRepeatIntervalMinutes: envInt("DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES", 30),
maxNaggingReminders: envInt("DEFAULT_MAX_NAGGING_REMINDERS", 5),
lowStockDays: envInt("DEFAULT_LOW_STOCK_DAYS", 30),
normalStockDays: envInt("DEFAULT_NORMAL_STOCK_DAYS", 90),
highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180),
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
shareMedicationOverview: envBool("DEFAULT_SHARE_MEDICATION_OVERVIEW", false),
upcomingTodayOnly: envBool("DEFAULT_UPCOMING_TODAY_ONLY", false),
shareScheduleTodayOnly: envBool("DEFAULT_SHARE_SCHEDULE_TODAY_ONLY", false),
swapDashboardMainSections: false,
lastAutoEmailSent: null,
lastNotificationType: null,
lastNotificationChannel: null,
lastReminderMedName: null,
lastReminderTakenBy: null,
lastStockReminderSent: null,
lastStockReminderChannel: null,
lastStockReminderMedNames: null,
lastPrescriptionReminderSent: null,
lastPrescriptionReminderChannel: null,
lastPrescriptionReminderMedNames: null,
};
}
type IntlWithSupportedValuesOf = typeof Intl & {
supportedValuesOf?: (key: string) => string[];
};
let cachedTimezones: Set<string> | null = null;
function getTimezoneSet(): Set<string> {
if (cachedTimezones) return cachedTimezones;
const intlWithSupportedValues = Intl as IntlWithSupportedValuesOf;
if (typeof intlWithSupportedValues.supportedValuesOf === "function") {
cachedTimezones = new Set(intlWithSupportedValues.supportedValuesOf("timeZone"));
return cachedTimezones;
}
cachedTimezones = new Set([process.env.TZ || "UTC", "UTC"]);
return cachedTimezones;
}
export function getAvailableTimezones(): string[] {
return [...getTimezoneSet()].sort((left, right) => left.localeCompare(right));
}
export function normalizeSettingsTimezone(value: string | null | undefined): string {
const trimmed = value?.trim() ?? "";
if (!trimmed) return "";
return getTimezoneSet().has(trimmed) ? trimmed : "";
}
export function validateNotificationHostname(hostnameRaw: string): string | null {
const hostname = hostnameRaw.toLowerCase();
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
return "Localhost URLs are not allowed";
}
const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (ipMatch) {
const [, a, b] = ipMatch.map(Number);
if (
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
) {
return "Private IP addresses are not allowed";
}
}
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".lan") ||
hostname === "metadata.google.internal"
) {
return "Internal hostnames are not allowed";
}
return null;
}
export function sanitizeNotificationUrl(
urlStr: string
): { url: string; isNtfy: boolean; auth?: { user: string; pass: string } } | { error: string } {
try {
if (urlStr.startsWith("discord://")) {
const parsedDiscord = new URL(urlStr);
const webhookId = parsedDiscord.hostname;
const webhookToken = parsedDiscord.username;
if (!webhookId || !webhookToken) {
return { error: "Invalid Discord URL format" };
}
if (!/^\d+$/.test(webhookId)) {
return { error: "Invalid Discord webhook ID" };
}
if (!/^[A-Za-z0-9._-]+$/.test(webhookToken)) {
return { error: "Invalid Discord webhook token" };
}
const discordWebhookUrl = `https://discord.com/api/webhooks/${webhookId}/${webhookToken}`;
return { url: discordWebhookUrl, isNtfy: false };
}
const isNtfy = isNtfyNotificationUrl(urlStr);
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
const parsed = new URL(normalizedUrl);
if (!["http:", "https:"].includes(parsed.protocol)) {
return { error: "Only HTTP/HTTPS protocols are allowed" };
}
const hostValidationError = validateNotificationHostname(parsed.hostname);
if (hostValidationError) {
return { error: hostValidationError };
}
const reconstructedUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}`;
const auth =
isNtfy && parsed.username && parsed.password ? { user: parsed.username, pass: parsed.password } : undefined;
return { url: reconstructedUrl, isNtfy, auth };
} catch {
return { error: "Invalid URL format" };
}
}
async function getOrCreateUserSettings(userId: number) {
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
if (!settings) {
[settings] = await db
.insert(userSettings)
.values({
userId,
...getDefaultSettings(),
})
.returning();
}
return settings;
}
export async function loadUserSettingsFromDb(userId: number): Promise<UserSettings> {
const settings = await getOrCreateUserSettings(userId);
return {
userId: settings.userId,
timezone: settings.timezone?.trim() ? settings.timezone : null,
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
shoutrrrEnabled: settings.shoutrrrEnabled,
shoutrrrUrl: settings.shoutrrrUrl,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareMedicationOverview: settings.shareMedicationOverview ?? false,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel,
lastReminderMedName: settings.lastReminderMedName ?? null,
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
lastStockReminderSent: settings.lastStockReminderSent ?? null,
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null,
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
};
}
export async function getAllUserSettingsFromDb(): Promise<UserSettings[]> {
const allSettings = await db.select().from(userSettings);
return allSettings.map((settings) => ({
userId: settings.userId,
timezone: settings.timezone?.trim() ? settings.timezone : null,
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
shoutrrrEnabled: settings.shoutrrrEnabled,
shoutrrrUrl: settings.shoutrrrUrl,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareMedicationOverview: settings.shareMedicationOverview ?? false,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel,
lastReminderMedName: settings.lastReminderMedName ?? null,
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
lastStockReminderSent: settings.lastStockReminderSent ?? null,
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null,
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
}));
}