refactor: decompose backend services and routes for maintainability

This commit is contained in:
Daniel Volz
2026-03-27 06:48:20 +01:00
committed by GitHub
parent 73a235dd83
commit b58c4fe5bb
32 changed files with 1527 additions and 1310 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ import { eq, sql } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js";
import { getDataDir } from "../db/path-utils.js";
import { refreshTokens, users } from "../db/schema.js";
import { getAuthState, requireAuth } from "../plugins/auth.js";
import type { AuthUser } from "../types/fastify.js";
+1 -1
View File
@@ -5,7 +5,7 @@ import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js";
import { getDataDir } from "../db/path-utils.js";
import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
+1 -1
View File
@@ -8,7 +8,7 @@ import {
type MedicationEnrichmentEnrichRequest,
MedicationEnrichmentServiceError,
searchMedicationEnrichment,
} from "../services/medication-enrichment.js";
} from "../services/medication-enrichment/index.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
+2 -77
View File
@@ -3,10 +3,11 @@ import { and, eq, like } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js";
import { getDataDir } from "../db/path-utils.js";
import { doseTracking, medications, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { calculateUsageInRange, normalizeDateTime, parseIntakesWithUnits } from "../services/medications-service.js";
import type { AuthUser } from "../types/fastify.js";
import {
ALLOWED_IMAGE_MIME_TYPES,
@@ -37,70 +38,12 @@ import {
type Intake,
normalizeIntake,
normalizeIntakeUsageForStock,
parseIntakesJson,
parseLocalDateTime,
parseTakenByJson,
} from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(getDataDir(), "images");
function isIntakeUnit(value: unknown): value is "ml" | "tsp" | "tbsp" {
return value === "ml" || value === "tsp" || value === "tbsp";
}
function parseRawIntakeUnits(intakesJson: string | null | undefined): Array<"ml" | "tsp" | "tbsp" | null> {
if (!intakesJson) return [];
try {
const parsed = JSON.parse(intakesJson);
if (!Array.isArray(parsed)) return [];
return parsed.map((item: unknown) => {
if (!item || typeof item !== "object") return null;
const unit = (item as Record<string, unknown>).intakeUnit;
return isIntakeUnit(unit) ? unit : null;
});
} catch {
return [];
}
}
function parseIntakesWithUnits(
intakesJson: string | null | undefined,
legacyRow: { usageJson: string; everyJson: string; startJson: string },
medicationIntakeRemindersEnabled?: boolean
): Intake[] {
const intakes = parseIntakesJson(intakesJson, legacyRow, medicationIntakeRemindersEnabled);
const rawUnits = parseRawIntakeUnits(intakesJson);
if (rawUnits.length === 0) return intakes;
return intakes.map((intake, idx) => ({
...intake,
intakeUnit: rawUnits[idx] ?? intake.intakeUnit ?? null,
}));
}
function normalizeDateTime(value: unknown): string | null {
if (value == null) {
return null;
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === "number") {
const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value;
const date = new Date(timestampMs);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
if (typeof value === "string") {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
return null;
}
// New intake schema with per-intake takenBy
const intakeSchema = z.object({
usage: z.number().nonnegative(),
@@ -1765,21 +1708,3 @@ export async function medicationRoutes(app: FastifyInstance) {
}
);
}
function calculateUsageInRange(
blisters: Array<Pick<Intake, "usage" | "every" | "start" | "scheduleMode" | "weekdays">>,
start: Date,
end: Date
) {
if (end.getTime() <= start.getTime()) {
return 0;
}
let total = 0;
blisters.forEach((blister) => {
forEachScheduledOccurrenceInRange(blister, start.getTime(), end.getTime() - 1, () => {
total += blister.usage;
});
});
return Number(total.toFixed(2));
}
+50 -153
View File
@@ -13,6 +13,14 @@ import {
} from "../i18n/translations.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import {
buildPrescriptionReminderPushNotification,
buildStockReminderPushNotification,
type PrescriptionReminderItem as SharedPrescriptionReminderItem,
type StockReminderItem as SharedStockReminderItem,
} from "../services/notifications/builders.js";
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "../services/notifications/delivery.js";
import { escapeHtml, getDeliveryError, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
import type { AuthUser } from "../types/fastify.js";
import {
@@ -20,56 +28,9 @@ import {
genericErrorSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import {
getPlannerUnitKind,
isAmountBasedPackageType,
isTubePackageType,
normalizePackageType,
} from "../utils/package-profiles.js";
import { isTubePackageType, normalizePackageType } from "../utils/package-profiles.js";
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
// Escape HTML to prevent XSS in email templates
function escapeHtml(text: string): string {
const htmlEscapes: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
}
type MailDeliveryInfo = {
accepted?: unknown;
rejected?: unknown;
response?: unknown;
};
function normalizeRecipients(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
.map((entry) => entry.trim())
.filter(Boolean);
}
function getDeliveryError(info: MailDeliveryInfo): string | null {
const accepted = normalizeRecipients(info.accepted);
const rejected = normalizeRecipients(info.rejected);
if (accepted.length > 0) return null;
if (rejected.length > 0) {
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
}
if (typeof info.response === "string" && info.response.trim()) {
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
}
return "SMTP did not confirm accepted recipients.";
}
type PlannerRow = {
medicationId: number;
medicationName: string;
@@ -83,17 +44,6 @@ type PlannerRow = {
packageType?: string;
};
function isContainerPackage(packageType?: string): boolean {
return isAmountBasedPackageType(packageType);
}
function getPlannerUnit(packageType: string | undefined, tr: ReturnType<typeof getTranslations>): string {
const unitKind = getPlannerUnitKind(packageType);
if (unitKind === "units") return tr.common.units;
if (unitKind === "ml") return tr.common.ml;
return tr.common.pills;
}
type SendEmailBody = {
email: string;
from: string;
@@ -682,7 +632,6 @@ ${getFooterPlain(language)}`;
if (lowStockMeds.length > 0) {
titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`);
}
const notificationTitle = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
// Build description text
let descriptionText: string;
@@ -723,28 +672,23 @@ ${getFooterPlain(language)}`;
// Send email if enabled
if (notificationSettings.emailEnabled && email) {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
const smtp = getSmtpConfig();
request.log.info(
{
userId,
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
hasSmtpPass: Boolean(smtpPass),
smtpPort,
smtpSecure,
hasSmtpFrom: Boolean(smtpFrom),
hasSmtpHost: Boolean(smtp.host),
hasSmtpUser: Boolean(smtp.user),
hasSmtpPass: Boolean(smtp.pass),
smtpPort: smtp.port,
smtpSecure: smtp.secure,
hasSmtpFrom: Boolean(smtp.from),
recipientEmail: email,
},
"[ReminderManual] Stock email path selected"
);
if (smtpHost && smtpUser) {
if (smtp.host && smtp.user) {
// Build subject line from shared title parts
const subjectText = titleParts.join(", ");
@@ -847,29 +791,18 @@ ${getFooterPlain(language)}`;
const plainText = `MedAssist-ng - ${tr.push.reorderNow}\n\n${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
},
});
request.log.info({ userId, recipientEmail: email }, "[ReminderManual] Sending stock reminder email");
const mailResult = await transporter.sendMail({
from: smtpFrom,
const mailResult = await sendEmailNotification({
to: email,
subject: `MedAssist-ng: ${subjectText}`,
text: plainText,
html,
from: smtp.from,
});
const deliveryError = getDeliveryError(mailResult);
if (deliveryError) {
throw new Error(deliveryError);
if (!mailResult.success) {
throw new Error(mailResult.error ?? "Unknown error");
}
request.log.info(
@@ -886,8 +819,8 @@ ${getFooterPlain(language)}`;
request.log.warn(
{
userId,
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
hasSmtpHost: Boolean(smtp.host),
hasSmtpUser: Boolean(smtp.user),
recipientEmail: email,
},
"[ReminderManual] Stock reminder email skipped: SMTP not configured"
@@ -902,13 +835,13 @@ ${getFooterPlain(language)}`;
// Send push notification if enabled
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
const pushPayload = buildStockReminderPushNotification(filteredLowStock as SharedStockReminderItem[], language);
try {
const pushResult = await sendShoutrrrNotification(
const pushResult = await sendPushNotification(
notificationSettings.shoutrrrUrl,
notificationTitle,
message
pushPayload.title,
pushPayload.message
);
if (pushResult.success) {
results.push = true;
@@ -1046,39 +979,24 @@ ${getFooterPlain(language)}`;
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
if (userSettings.emailEnabled && userSettings.emailPrescriptionReminders && email) {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
const smtp = getSmtpConfig();
request.log.info(
{
userId,
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
hasSmtpPass: Boolean(smtpPass),
smtpPort,
smtpSecure,
hasSmtpFrom: Boolean(smtpFrom),
hasSmtpHost: Boolean(smtp.host),
hasSmtpUser: Boolean(smtp.user),
hasSmtpPass: Boolean(smtp.pass),
smtpPort: smtp.port,
smtpSecure: smtp.secure,
hasSmtpFrom: Boolean(smtp.from),
recipientEmail: email,
},
"[ReminderManual] Prescription email path selected"
);
if (smtpHost && smtpUser) {
if (smtp.host && smtp.user) {
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
},
});
const subject =
filteredPrescriptionLow.length === 1
? tr.prescriptionReminder.subjectSingle
@@ -1152,17 +1070,16 @@ ${getFooterPlain(language)}`;
request.log.info({ userId, recipientEmail: email }, "[ReminderManual] Sending prescription reminder email");
const mailResult = await transporter.sendMail({
from: smtpFrom,
const mailResult = await sendEmailNotification({
to: email,
subject,
text,
html,
from: smtp.from,
});
const deliveryError = getDeliveryError(mailResult);
if (deliveryError) {
throw new Error(deliveryError);
if (!mailResult.success) {
throw new Error(mailResult.error ?? "Unknown error");
}
request.log.info(
@@ -1182,8 +1099,8 @@ ${getFooterPlain(language)}`;
request.log.warn(
{
userId,
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
hasSmtpHost: Boolean(smtp.host),
hasSmtpUser: Boolean(smtp.user),
recipientEmail: email,
},
"[ReminderManual] Prescription reminder email skipped: SMTP not configured"
@@ -1201,37 +1118,17 @@ ${getFooterPlain(language)}`;
}
if (userSettings.shoutrrrEnabled && userSettings.shoutrrrPrescriptionReminders && userSettings.shoutrrrUrl) {
const titleParts: string[] = [];
if (emptyRx.length > 0)
titleParts.push(
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
);
if (lowRx.length > 0)
titleParts.push(
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
);
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
const messageParts: string[] = [];
if (emptyRx.length > 0) {
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
for (const m of emptyRx) {
messageParts.push(`${m.name}`);
}
}
if (lowRx.length > 0) {
if (emptyRx.length > 0) messageParts.push("");
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
for (const m of lowRx) {
messageParts.push(
`${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
);
}
}
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
const pushPayload = buildPrescriptionReminderPushNotification(
filteredPrescriptionLow as SharedPrescriptionReminderItem[],
language
);
try {
const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message);
const pushResult = await sendPushNotification(
userSettings.shoutrrrUrl,
pushPayload.title,
pushPayload.message
);
if (pushResult.success) {
results.push = true;
} else {
+13 -311
View File
@@ -3,51 +3,21 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import nodemailer from "nodemailer";
import { db } from "../db/client.js";
import { userSettings } from "../db/schema.js";
import type { Language } from "../i18n/translations.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import {
classifyTestEmailFailure,
getAllUserSettingsFromDb,
getDefaultSettings,
getNotificationProvider,
loadUserSettingsFromDb,
sanitizeNotificationUrl,
type UserSettings,
validateNotificationHostname,
} from "../services/settings-service.js";
import type { AuthUser } from "../types/fastify.js";
// Exported type for use in schedulers
export type UserSettings = {
userId: number;
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 type { UserSettings } from "../services/settings-service.js";
type SettingsBody = {
emailEnabled: boolean;
@@ -127,61 +97,6 @@ function getDeliveryError(info: MailDeliveryInfo): string | null {
return "SMTP did not confirm accepted recipients.";
}
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}`,
};
}
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 (url.startsWith("ntfy://")) return "ntfy";
try {
const parsed = new URL(url);
return parsed.hostname || "https";
} catch {
return "unknown";
}
}
// Helper to parse boolean env vars
function envBool(key: string, defaultVal: boolean): boolean {
const val = process.env[key];
if (val === undefined) return defaultVal;
return val === "true" || val === "1";
}
// Helper to parse integer env vars
function envInt(key: string, defaultVal: number): number {
const val = process.env[key];
if (val === undefined) return defaultVal;
@@ -189,54 +104,10 @@ function envInt(key: string, defaultVal: number): number {
return Number.isNaN(parsed) ? defaultVal : parsed;
}
// Default settings for new users - read from ENV with fallbacks
function getDefaultSettings() {
return {
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,
};
}
// Helper to get or create user settings
async function getOrCreateUserSettings(userId: number) {
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
if (!settings) {
// Create default settings for user (using ENV defaults)
[settings] = await db
.insert(userSettings)
.values({
@@ -251,90 +122,12 @@ async function getOrCreateUserSettings(userId: number) {
// Export for use in reminder scheduler
export async function loadUserSettings(userId: number): Promise<UserSettings> {
const settings = await getOrCreateUserSettings(userId);
return {
userId: settings.userId,
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,
};
return loadUserSettingsFromDb(userId);
}
// Get all users with settings for scheduler
export async function getAllUserSettings(): Promise<UserSettings[]> {
const allSettings = await db.select().from(userSettings);
return allSettings.map((settings) => ({
userId: settings.userId,
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,
}));
return getAllUserSettingsFromDb();
}
export async function settingsRoutes(app: FastifyInstance) {
@@ -792,97 +585,6 @@ export async function settingsRoutes(app: FastifyInstance) {
);
}
// Validate and sanitize URL to prevent SSRF attacks
// Returns a reconstructed URL from validated components to break taint tracking
function sanitizeNotificationUrl(
urlStr: string
): { url: string; isNtfy: boolean; auth?: { user: string; pass: string } } | { error: string } {
try {
// Support Shoutrrr Discord format: discord://TOKEN@WEBHOOK_ID
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 };
}
// Convert ntfy:// to https:// for parsing, track if it was ntfy
const isNtfy = urlStr.startsWith("ntfy://");
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
const parsed = new URL(normalizedUrl);
// Only allow http and https protocols
if (!["http:", "https:"].includes(parsed.protocol)) {
return { error: "Only HTTP/HTTPS protocols are allowed" };
}
const hostValidationError = validateNotificationHostname(parsed.hostname);
if (hostValidationError) {
return { error: hostValidationError };
}
// Reconstruct URL from validated components - this breaks taint tracking
// because we're building a new string from validated parts, not passing through user input
const reconstructedUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}`;
// Extract auth credentials separately for ntfy (they're in the URL but not in host)
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" };
}
}
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;
}
// Send notification via Shoutrrr-compatible URL (supports ntfy, Discord, Telegram, etc.)
export async function sendShoutrrrNotification(
urlStr: string,