refactor: decompose backend services and routes for maintainability
This commit is contained in:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user