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
@@ -0,0 +1,109 @@
import { getFooterPlain, getTranslations, type Language, t } from "../../i18n/translations.js";
export type StockReminderItem = {
name: string;
medsLeft: number;
daysLeft: number | null;
depletionDate: string | null;
isCritical?: boolean;
};
export type PrescriptionReminderItem = {
name: string;
remainingRefills: number;
};
function splitStockItems(items: StockReminderItem[]): {
emptyItems: StockReminderItem[];
criticalItems: StockReminderItem[];
lowItems: StockReminderItem[];
} {
const emptyItems = items.filter((item) => item.medsLeft <= 0);
const criticalItems = items.filter((item) => item.medsLeft > 0 && item.isCritical !== false);
const lowItems = items.filter((item) => item.medsLeft > 0 && item.isCritical === false);
return { emptyItems, criticalItems, lowItems };
}
export function buildStockReminderPushNotification(
items: StockReminderItem[],
language: Language
): { title: string; message: string } {
const tr = getTranslations(language);
const { emptyItems, criticalItems, lowItems } = splitStockItems(items);
const titleParts: string[] = [];
if (emptyItems.length > 0) titleParts.push(`🚨 ${emptyItems.length} ${tr.push.empty}`);
if (criticalItems.length > 0) titleParts.push(`🚨 ${criticalItems.length} ${tr.push.critical}`);
if (lowItems.length > 0) titleParts.push(`⚠️ ${lowItems.length} ${tr.push.lowStock}`);
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
const messageParts: string[] = [];
if (emptyItems.length > 0) {
messageParts.push(`🚨 ${tr.push.emptySection}:`);
emptyItems.forEach((item) => messageParts.push(`${item.name}`));
}
if (criticalItems.length > 0) {
if (messageParts.length > 0) messageParts.push("");
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
criticalItems.forEach((item) =>
messageParts.push(
`${item.name}: ${t(tr.push.pillsLeft, { count: item.medsLeft })}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
)
);
}
if (lowItems.length > 0) {
if (messageParts.length > 0) messageParts.push("");
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
lowItems.forEach((item) =>
messageParts.push(
`${item.name}: ${t(tr.push.pillsLeft, { count: item.medsLeft })}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
)
);
}
return {
title,
message: `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`,
};
}
export function buildPrescriptionReminderPushNotification(
items: PrescriptionReminderItem[],
language: Language
): { title: string; message: string } {
const tr = getTranslations(language);
const emptyItems = items.filter((item) => item.remainingRefills <= 0);
const lowItems = items.filter((item) => item.remainingRefills > 0);
const titleParts: string[] = [];
if (emptyItems.length > 0) {
titleParts.push(
`🚨 ${emptyItems.length} ${emptyItems.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
);
}
if (lowItems.length > 0) {
titleParts.push(
`🚨 ${lowItems.length} ${lowItems.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
);
}
const messageParts: string[] = [];
if (emptyItems.length > 0) {
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
emptyItems.forEach((item) => messageParts.push(`${item.name}`));
}
if (lowItems.length > 0) {
if (messageParts.length > 0) messageParts.push("");
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
lowItems.forEach((item) =>
messageParts.push(
`${item.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: item.remainingRefills })}`
)
);
}
return {
title: `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`,
message: `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`,
};
}
@@ -0,0 +1,123 @@
import nodemailer from "nodemailer";
import { sendShoutrrrNotification } from "../../routes/settings.js";
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.";
}
export type EmailDeliveryRequest = {
to: string;
subject: string;
text: string;
html: string;
from?: string;
};
export type EmailDeliveryResult = {
success: boolean;
error?: string;
messageId?: string;
smtpResponse?: string;
};
export function getSmtpConfig(): {
host?: string;
user?: string;
pass?: string;
port: number;
secure: boolean;
from?: string;
} {
const host = process.env.SMTP_HOST;
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
const port = parseInt(process.env.SMTP_PORT ?? "587", 10);
const secure = process.env.SMTP_SECURE === "true";
const from = process.env.SMTP_FROM ?? user;
return { host, user, pass, port, secure, from };
}
export async function sendEmailNotification(input: EmailDeliveryRequest): Promise<EmailDeliveryResult> {
const smtp = getSmtpConfig();
if (!smtp.host || !smtp.user) {
return { success: false, error: "SMTP not configured" };
}
try {
const transporter = nodemailer.createTransport({
host: smtp.host,
port: smtp.port,
secure: smtp.secure,
auth: {
user: smtp.user,
pass: smtp.pass ?? "",
},
});
const mailResult = await transporter.sendMail({
from: input.from ?? smtp.from,
to: input.to,
subject: input.subject,
text: input.text,
html: input.html,
});
const deliveryError = getDeliveryError(mailResult);
if (deliveryError) {
return { success: false, error: deliveryError };
}
return {
success: true,
messageId: mailResult.messageId,
smtpResponse: typeof mailResult.response === "string" ? mailResult.response : undefined,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: errorMessage };
}
}
export async function sendPushNotification(
url: string,
title: string,
message: string
): Promise<{ success: boolean; error?: string }> {
try {
const result = await sendShoutrrrNotification(url, title, message);
if (!result.success) {
return { success: false, error: result.error };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: errorMessage };
}
}
@@ -0,0 +1,20 @@
export {
buildPrescriptionReminderPushNotification,
buildStockReminderPushNotification,
type PrescriptionReminderItem,
type StockReminderItem,
} from "./builders.js";
export {
type EmailDeliveryRequest,
type EmailDeliveryResult,
getSmtpConfig,
sendEmailNotification,
sendPushNotification,
} from "./delivery.js";
export {
getReminderState,
loadReminderState,
saveReminderState,
updateReminderSentTime,
updateUserReminderSentTime,
} from "./state.js";
@@ -0,0 +1,93 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { eq } from "drizzle-orm";
import { db } from "../../db/client.js";
import { getDataDir } from "../../db/db-utils.js";
import { userSettings } from "../../db/schema.js";
import {
createDefaultReminderState,
getTodayInTimezone,
parseReminderState,
type ReminderState,
} from "../../utils/scheduler-utils.js";
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
export function loadReminderState(): ReminderState {
try {
if (existsSync(reminderStateFile)) {
return parseReminderState(readFileSync(reminderStateFile, "utf-8"));
}
} catch {
// ignore
}
return createDefaultReminderState();
}
export function saveReminderState(state: ReminderState): void {
writeFileSync(reminderStateFile, JSON.stringify(state, null, 2));
}
export function getReminderState(): ReminderState {
return loadReminderState();
}
export function updateReminderSentTime(
type: "stock" | "intake" | "prescription" = "stock",
channel: "email" | "push" | "both" = "email"
): void {
const state = loadReminderState();
const today = getTodayInTimezone();
saveReminderState({
...state,
lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today,
lastNotificationType: type,
lastNotificationChannel: channel,
});
}
// Stock and intake reminders are tracked separately so neither overwrites the other.
export async function updateUserReminderSentTime(
userId: number,
type: "stock" | "intake" | "prescription" = "stock",
channel: "email" | "push" | "both" = "email",
medName?: string,
takenBy?: string
): Promise<void> {
const now = new Date().toISOString();
if (type === "stock") {
await db
.update(userSettings)
.set({
lastStockReminderSent: now,
lastStockReminderChannel: channel,
lastStockReminderMedNames: medName ?? null,
})
.where(eq(userSettings.userId, userId));
return;
}
if (type === "prescription") {
await db
.update(userSettings)
.set({
lastPrescriptionReminderSent: now,
lastPrescriptionReminderChannel: channel,
lastPrescriptionReminderMedNames: medName ?? null,
})
.where(eq(userSettings.userId, userId));
return;
}
await db
.update(userSettings)
.set({
lastAutoEmailSent: now,
lastNotificationType: type,
lastNotificationChannel: channel,
lastReminderMedName: medName ?? null,
lastReminderTakenBy: takenBy ?? null,
})
.where(eq(userSettings.userId, userId));
}