diff --git a/backend/package-lock.json b/backend/package-lock.json index 0cbdc10..81acd6e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "medassist-ng-backend", - "version": "1.17.0", + "version": "1.17.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "medassist-ng-backend", - "version": "1.17.0", + "version": "1.17.1", "dependencies": { "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index 06a191f..11606ec 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -24,6 +24,40 @@ import { 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).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, + })); +} + // New intake schema with per-intake takenBy const intakeSchema = z.object({ usage: z.number().nonnegative(), @@ -246,7 +280,7 @@ export async function medicationRoutes(app: FastifyInstance) { const rows = await db.select().from(medications).where(whereClause).orderBy(medications.id); return rows.map((row) => { // Parse intakes from new format, falling back to legacy - const intakes = parseIntakesJson( + const intakes = parseIntakesWithUnits( row.intakesJson, { usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson }, row.intakeRemindersEnabled ?? false @@ -586,7 +620,7 @@ export async function medicationRoutes(app: FastifyInstance) { // Migrate dose tracking IDs when intake schedule changes // --------------------------------------------------------------- // Parse old intakes from the existing medication row - const oldIntakes = parseIntakesJson( + const oldIntakes = parseIntakesWithUnits( existing.intakesJson, { usageJson: existing.usageJson, everyJson: existing.everyJson, startJson: existing.startJson }, existing.intakeRemindersEnabled @@ -799,62 +833,101 @@ export async function medicationRoutes(app: FastifyInstance) { }; }); - // Stock correction endpoint - updates stockAdjustment and optionally looseTablets (for blister type) + // Stock correction endpoint - updates stockAdjustment and optionally base amount fields for amount-based corrections // Also sets lastStockCorrectionAt so consumed doses before this point don't count - app.patch<{ Params: { id: string }; Body: { stockAdjustment: number; looseTablets?: number } }>( - "/medications/:id/stock-adjustment", - async (req, reply) => { - const idNum = Number(req.params.id); - if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); + app.patch<{ + Params: { id: string }; + Body: { + stockAdjustment: number; + looseTablets?: number; + totalPills?: number; + packageAmountValue?: number; + packCount?: number; + }; + }>("/medications/:id/stock-adjustment", async (req, reply) => { + const idNum = Number(req.params.id); + if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - const userId = await getUserId(req, reply); + const userId = await getUserId(req, reply); - // Verify ownership - const [existing] = await db - .select() - .from(medications) - .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); - if (!existing) return reply.notFound(); + // Verify ownership + const [existing] = await db + .select() + .from(medications) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); + if (!existing) return reply.notFound(); - const { stockAdjustment, looseTablets } = req.body as { stockAdjustment: number; looseTablets?: number }; - if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number"); - if ( - looseTablets !== undefined && - (typeof looseTablets !== "number" || !Number.isInteger(looseTablets) || looseTablets < 0) - ) { - return reply.badRequest("looseTablets must be a non-negative integer"); - } - - const updateFields: { - stockAdjustment: number; - lastStockCorrectionAt: Date; - updatedAt: Date; - looseTablets?: number; - } = { - stockAdjustment, - lastStockCorrectionAt: new Date(), - updatedAt: new Date(), - }; - if (looseTablets !== undefined) { - updateFields.looseTablets = looseTablets; - } - - const result = await db - .update(medications) - .set(updateFields) - .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) - .returning(); - - if (!result.length) return reply.notFound(); - - return { - id: result[0].id, - stockAdjustment: result[0].stockAdjustment ?? 0, - lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null, - updatedAt: result[0].updatedAt, - }; + const { stockAdjustment, looseTablets, totalPills, packageAmountValue, packCount } = req.body as { + stockAdjustment: number; + looseTablets?: number; + totalPills?: number; + packageAmountValue?: number; + packCount?: number; + }; + if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number"); + if ( + looseTablets !== undefined && + (typeof looseTablets !== "number" || !Number.isInteger(looseTablets) || looseTablets < 0) + ) { + return reply.badRequest("looseTablets must be a non-negative integer"); } - ); + if ( + totalPills !== undefined && + (typeof totalPills !== "number" || !Number.isInteger(totalPills) || totalPills < 0) + ) { + return reply.badRequest("totalPills must be a non-negative integer"); + } + if ( + packageAmountValue !== undefined && + (typeof packageAmountValue !== "number" || !Number.isInteger(packageAmountValue) || packageAmountValue < 0) + ) { + return reply.badRequest("packageAmountValue must be a non-negative integer"); + } + if (packCount !== undefined && (typeof packCount !== "number" || !Number.isInteger(packCount) || packCount < 1)) { + return reply.badRequest("packCount must be an integer >= 1"); + } + + const updateFields: { + stockAdjustment: number; + lastStockCorrectionAt: Date; + updatedAt: Date; + looseTablets?: number; + totalPills?: number | null; + packageAmountValue?: number; + packCount?: number; + } = { + stockAdjustment, + lastStockCorrectionAt: new Date(), + updatedAt: new Date(), + }; + + const packageType = existing.packageType ?? "blister"; + const allowsAmountBaseUpdate = packageType === "tube" || packageType === "liquid_container"; + if (allowsAmountBaseUpdate) { + if (totalPills !== undefined) updateFields.totalPills = totalPills; + if (looseTablets !== undefined) updateFields.looseTablets = looseTablets; + if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue; + if (packCount !== undefined) updateFields.packCount = packCount; + } + if (looseTablets !== undefined) { + updateFields.looseTablets = looseTablets; + } + + const result = await db + .update(medications) + .set(updateFields) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) + .returning(); + + if (!result.length) return reply.notFound(); + + return { + id: result[0].id, + stockAdjustment: result[0].stockAdjustment ?? 0, + lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null, + updatedAt: result[0].updatedAt, + }; + }); app.delete<{ Params: { id: string } }>("/medications/:id", async (req, reply) => { const idNum = Number(req.params.id); @@ -1008,7 +1081,7 @@ export async function medicationRoutes(app: FastifyInstance) { const payload = rows.map((row) => { // Parse intakes from new format, falling back to legacy - const intakes = parseIntakesJson( + const intakes = parseIntakesWithUnits( row.intakesJson, { usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson }, row.intakeRemindersEnabled ?? false diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts index d4aec31..cf71a55 100644 --- a/backend/src/routes/planner.ts +++ b/backend/src/routes/planner.ts @@ -29,6 +29,43 @@ function escapeHtml(text: string): string { return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char); } +function maskEmail(email: string): string { + const [localPart, domain] = email.split("@"); + if (!domain) return "invalid-email"; + if (localPart.length <= 2) return `${localPart[0] ?? "*"}*@${domain}`; + return `${localPart.slice(0, 2)}***@${domain}`; +} + +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; @@ -106,6 +143,10 @@ export async function plannerRoutes(app: FastifyInstance) { // Demand calculator notification (supports email and push) app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => { const { email, from, until, rows, language: bodyLanguage } = request.body; + request.log.info( + { hasEmail: Boolean(email), rowCount: rows?.length ?? 0 }, + "[Planner] Demand notification request received" + ); if (!rows || rows.length === 0) { return reply.status(400).send({ error: "Missing planner data" }); @@ -120,6 +161,7 @@ export async function plannerRoutes(app: FastifyInstance) { const activeMedIds = new Set(activeMeds.map((med) => med.id)); const activeRows = rows.filter((row) => activeMedIds.has(row.medicationId)); if (activeRows.length === 0) { + request.log.warn("[Planner] Demand notification skipped: no active medications in request"); return reply.status(400).send({ error: "No active medications to notify" }); } @@ -129,6 +171,16 @@ export async function plannerRoutes(app: FastifyInstance) { shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl || "", }; + request.log.info( + { + userId, + emailEnabled: notificationSettings.emailEnabled, + pushEnabled: notificationSettings.shoutrrrEnabled, + hasPushUrl: Boolean(notificationSettings.shoutrrrUrl), + activeRowCount: activeRows.length, + }, + "[Planner] Demand notification channel state" + ); // Get locale from user settings or use the language passed in the body const language: Language = (userSettings.language as Language) || bodyLanguage || "en"; @@ -210,6 +262,19 @@ ${getFooterPlain(language)}`; const smtpSecure = process.env.SMTP_SECURE === "true"; const smtpFrom = process.env.SMTP_FROM ?? smtpUser; + request.log.info( + { + hasSmtpHost: Boolean(smtpHost), + hasSmtpUser: Boolean(smtpUser), + hasSmtpPass: Boolean(smtpPass), + smtpPort, + smtpSecure, + hasSmtpFrom: Boolean(smtpFrom), + to: maskEmail(email), + }, + "[Planner] Demand email path selected" + ); + if (smtpHost && smtpUser) { // Build HTML table with horizontal scroll for mobile // Escape/coerce all user-provided values to prevent XSS @@ -316,7 +381,9 @@ ${getFooterPlain(language)}`; }, }); - await transporter.sendMail({ + request.log.info({ to: maskEmail(email) }, "[Planner] Sending demand email"); + + const mailResult = await transporter.sendMail({ from: smtpFrom, to: email, subject: t(dc.subject, { from: fromDate, until: untilDate }), @@ -324,12 +391,33 @@ ${getFooterPlain(language)}`; html, }); + const deliveryError = getDeliveryError(mailResult); + if (deliveryError) { + throw new Error(deliveryError); + } + + request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Planner] Demand email sent"); results.email = true; } catch (error) { + request.log.error({ error, to: maskEmail(email) }, "[Planner] Demand email failed"); const errorMessage = error instanceof Error ? error.message : "Unknown error"; results.errors.push(`Email: ${errorMessage}`); } + } else { + request.log.warn( + { + hasSmtpHost: Boolean(smtpHost), + hasSmtpUser: Boolean(smtpUser), + to: maskEmail(email), + }, + "[Planner] Demand email skipped: SMTP not configured" + ); } + } else { + request.log.info( + { emailEnabled: notificationSettings.emailEnabled, hasRecipient: Boolean(email) }, + "[Planner] Demand email channel not active" + ); } // Send push notification if enabled @@ -376,6 +464,10 @@ ${getFooterPlain(language)}`; // Reminder notification for low stock medications (supports email and push) app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => { const { email, lowStock } = request.body; + request.log.info( + { hasEmail: Boolean(email), lowStockCount: lowStock?.length ?? 0 }, + "[ReminderManual] Stock reminder request received" + ); if (!lowStock || lowStock.length === 0) { return reply.status(400).send({ error: "Missing low stock data" }); @@ -384,12 +476,22 @@ ${getFooterPlain(language)}`; // Load user settings const userId = await getUserId(request); const activeMeds = await db - .select({ name: medications.name, genericName: medications.genericName }) + .select({ name: medications.name, genericName: medications.genericName, packageType: medications.packageType }) .from(medications) .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))); - const activeMedNames = new Set(activeMeds.map((med) => med.name || med.genericName || "")); - const filteredLowStock = lowStock.filter((item) => activeMedNames.has(item.name)); + const activeMedicationByName = new Map( + activeMeds + .map((med) => [med.name || med.genericName || "", med.packageType ?? "blister"] as const) + .filter(([name]) => name.length > 0) + ); + const filteredLowStock = lowStock.filter((item) => { + const packageType = activeMedicationByName.get(item.name); + if (!packageType) return false; + if (packageType === "tube") return false; + return true; + }); if (filteredLowStock.length === 0) { + request.log.warn("[ReminderManual] Stock reminder skipped: no active medications after filtering"); return reply.status(400).send({ error: "No active medications to notify" }); } @@ -399,6 +501,16 @@ ${getFooterPlain(language)}`; shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl || "", }; + request.log.info( + { + userId, + emailEnabled: notificationSettings.emailEnabled, + pushEnabled: notificationSettings.shoutrrrEnabled, + hasPushUrl: Boolean(notificationSettings.shoutrrrUrl), + filteredLowStockCount: filteredLowStock.length, + }, + "[ReminderManual] Stock reminder channel state" + ); // Get translations based on user language const language = (userSettings.language as Language) || "en"; @@ -470,6 +582,19 @@ ${getFooterPlain(language)}`; const smtpSecure = process.env.SMTP_SECURE === "true"; const smtpFrom = process.env.SMTP_FROM ?? smtpUser; + request.log.info( + { + hasSmtpHost: Boolean(smtpHost), + hasSmtpUser: Boolean(smtpUser), + hasSmtpPass: Boolean(smtpPass), + smtpPort, + smtpSecure, + hasSmtpFrom: Boolean(smtpFrom), + to: maskEmail(email), + }, + "[ReminderManual] Stock email path selected" + ); + if (smtpHost && smtpUser) { // Build subject line from shared title parts const subjectText = titleParts.join(", "); @@ -583,7 +708,9 @@ ${getFooterPlain(language)}`; }, }); - await transporter.sendMail({ + request.log.info({ to: maskEmail(email) }, "[ReminderManual] Sending stock reminder email"); + + const mailResult = await transporter.sendMail({ from: smtpFrom, to: email, subject: `MedAssist-ng: ${subjectText}`, @@ -591,12 +718,36 @@ ${getFooterPlain(language)}`; html, }); + const deliveryError = getDeliveryError(mailResult); + if (deliveryError) { + throw new Error(deliveryError); + } + + request.log.info( + { to: maskEmail(email), messageId: mailResult.messageId }, + "[ReminderManual] Stock reminder email sent" + ); results.email = true; } catch (error) { + request.log.error({ error, to: maskEmail(email) }, "[ReminderManual] Stock reminder email failed"); const errorMessage = error instanceof Error ? error.message : "Unknown error"; results.errors.push(`Email: ${errorMessage}`); } + } else { + request.log.warn( + { + hasSmtpHost: Boolean(smtpHost), + hasSmtpUser: Boolean(smtpUser), + to: maskEmail(email), + }, + "[ReminderManual] Stock reminder email skipped: SMTP not configured" + ); } + } else { + request.log.info( + { emailEnabled: notificationSettings.emailEnabled, hasRecipient: Boolean(email) }, + "[ReminderManual] Stock email channel not active" + ); } // Send push notification if enabled @@ -647,6 +798,10 @@ ${getFooterPlain(language)}`; // Manual prescription reminder (supports email and push) app.post<{ Body: PrescriptionReminderBody }>("/reminder/send-prescription", async (request, reply) => { const { email, prescriptionLow } = request.body; + request.log.info( + { hasEmail: Boolean(email), prescriptionCount: prescriptionLow?.length ?? 0 }, + "[ReminderManual] Prescription reminder request received" + ); if (!prescriptionLow || prescriptionLow.length === 0) { return reply.status(400).send({ error: "Missing prescription reminder data" }); @@ -660,6 +815,7 @@ ${getFooterPlain(language)}`; const activeMedNames = new Set(activeMeds.map((med) => med.name || med.genericName || "")); const filteredPrescriptionLow = prescriptionLow.filter((item) => activeMedNames.has(item.name)); if (filteredPrescriptionLow.length === 0) { + request.log.warn("[ReminderManual] Prescription reminder skipped: no active medications after filtering"); return reply.status(400).send({ error: "No active medications to notify" }); } @@ -697,6 +853,19 @@ ${getFooterPlain(language)}`; const smtpSecure = process.env.SMTP_SECURE === "true"; const smtpFrom = process.env.SMTP_FROM ?? smtpUser; + request.log.info( + { + hasSmtpHost: Boolean(smtpHost), + hasSmtpUser: Boolean(smtpUser), + hasSmtpPass: Boolean(smtpPass), + smtpPort, + smtpSecure, + hasSmtpFrom: Boolean(smtpFrom), + to: maskEmail(email), + }, + "[ReminderManual] Prescription email path selected" + ); + if (smtpHost && smtpUser) { try { const transporter = nodemailer.createTransport({ @@ -780,7 +949,9 @@ ${getFooterPlain(language)}`; `; - await transporter.sendMail({ + request.log.info({ to: maskEmail(email) }, "[ReminderManual] Sending prescription reminder email"); + + const mailResult = await transporter.sendMail({ from: smtpFrom, to: email, subject, @@ -788,12 +959,40 @@ ${getFooterPlain(language)}`; html, }); + const deliveryError = getDeliveryError(mailResult); + if (deliveryError) { + throw new Error(deliveryError); + } + + request.log.info( + { to: maskEmail(email), messageId: mailResult.messageId }, + "[ReminderManual] Prescription reminder email sent" + ); results.email = true; } catch (error) { + request.log.error({ error, to: maskEmail(email) }, "[ReminderManual] Prescription reminder email failed"); const errorMessage = error instanceof Error ? error.message : "Unknown error"; results.errors.push(`Email: ${errorMessage}`); } + } else { + request.log.warn( + { + hasSmtpHost: Boolean(smtpHost), + hasSmtpUser: Boolean(smtpUser), + to: maskEmail(email), + }, + "[ReminderManual] Prescription reminder email skipped: SMTP not configured" + ); } + } else { + request.log.info( + { + emailEnabled: userSettings.emailEnabled, + emailPrescriptionReminders: userSettings.emailPrescriptionReminders, + hasRecipient: Boolean(email), + }, + "[ReminderManual] Prescription email channel not active" + ); } if (userSettings.shoutrrrEnabled && userSettings.shoutrrrPrescriptionReminders && userSettings.shoutrrrUrl) { diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index dc50e97..0da6980 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -85,6 +85,43 @@ type TestShoutrrrBody = { url: string; }; +function maskEmail(email: string): string { + const [localPart, domain] = email.split("@"); + if (!domain) return "invalid-email"; + if (localPart.length <= 2) return `${localPart[0] ?? "*"}*@${domain}`; + return `${localPart.slice(0, 2)}***@${domain}`; +} + +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."; +} + function getNotificationProvider(url: string): string { if (url.startsWith("discord://")) return "discord"; if (url.startsWith("telegram://")) return "telegram"; @@ -436,7 +473,24 @@ export async function settingsRoutes(app: FastifyInstance) { const smtpSecure = process.env.SMTP_SECURE === "true"; const smtpFrom = process.env.SMTP_FROM ?? smtpUser; + request.log.info( + { + to: maskEmail(email), + hasSmtpHost: Boolean(smtpHost), + hasSmtpUser: Boolean(smtpUser), + hasSmtpPass: Boolean(smtpPass), + hasSmtpFrom: Boolean(smtpFrom), + smtpPort, + smtpSecure, + }, + "[Settings] Test email request received" + ); + if (!smtpHost || !smtpUser) { + request.log.warn( + { to: maskEmail(email), hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) }, + "[Settings] Test email skipped: SMTP not configured" + ); return reply.status(400).send({ error: "SMTP not configured" }); } @@ -451,7 +505,9 @@ export async function settingsRoutes(app: FastifyInstance) { }, }); - await transporter.sendMail({ + request.log.info({ to: maskEmail(email) }, "[Settings] Sending test email"); + + const mailResult = await transporter.sendMail({ from: smtpFrom, to: email, subject: "MedAssist-ng - Test Email", @@ -467,8 +523,16 @@ export async function settingsRoutes(app: FastifyInstance) { `, }); + const deliveryError = getDeliveryError(mailResult); + if (deliveryError) { + throw new Error(deliveryError); + } + + request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Settings] Test email sent"); + return reply.send({ success: true, message: "Test email sent successfully" }); } catch (error) { + request.log.error({ error, to: maskEmail(email) }, "[Settings] Test email failed"); const errorMessage = error instanceof Error ? error.message : "Unknown error"; return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` }); } diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index e5fad24..54c2731 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -19,6 +19,7 @@ import { getNextScheduledTime, getTimezone, getTodayInTimezone, + normalizeIntakeUsageForStock, parseIntakesJson, parseLocalDateTime, parseReminderState, @@ -37,6 +38,36 @@ function escapeHtml(text: string): string { 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."; +} + const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time const reminderStateFile = resolve(getDataDir(), "reminder-state.json"); @@ -179,6 +210,12 @@ type LowStockItem = { isCritical: boolean; }; +function getLiquidReminderThresholds(baselineDays: number): { lowDays: number; criticalDays: number } { + const lowDays = Math.max(1, Math.floor(baselineDays)); + const criticalDays = Math.max(1, Math.ceil(lowDays / 2)); + return { lowDays, criticalDays }; +} + type PrescriptionReminderItem = { name: string; remainingRefills: number; @@ -231,12 +268,20 @@ async function getMedicationsNeedingReminder( const msPerDay = 86_400_000; for (const row of rows) { + // Tube stock reminders are intentionally disabled: + // topical usage in grams cannot be mapped reliably to schedule events. + if ((row.packageType ?? "blister") === "tube") continue; + const intakes = parseIntakesJson( row.intakesJson, { usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson }, row.intakeRemindersEnabled ?? false ); - const blisters: Blister[] = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })); + const blisters: Blister[] = intakes.map((i) => ({ + usage: normalizeIntakeUsageForStock(i, row.medicationForm, row.packageType), + every: i.every, + start: i.start, + })); const originalTotalPills = (row.packageType ?? "blister") === "bottle" @@ -348,8 +393,13 @@ async function getMedicationsNeedingReminder( if (daysLeft === null) continue; - const isCritical = daysLeft <= reminderDaysBefore; - const isLow = daysLeft < lowStockDays; + const isLiquid = (row.packageType ?? "blister") === "liquid_container"; + const { lowDays, criticalDays } = isLiquid + ? getLiquidReminderThresholds(reminderDaysBefore) + : { lowDays: lowStockDays, criticalDays: reminderDaysBefore }; + + const isCritical = daysLeft <= criticalDays; + const isLow = isLiquid ? daysLeft <= lowDays : daysLeft < lowDays; if (isCritical || isLow) { lowStock.push({ @@ -551,7 +601,7 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily }, }); - await transporter.sendMail({ + const mailResult = await transporter.sendMail({ from: smtpFrom, to: email, subject, @@ -559,6 +609,11 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily html, }); + const deliveryError = getDeliveryError(mailResult); + if (deliveryError) { + throw new Error(deliveryError); + } + return { success: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; @@ -872,13 +927,17 @@ async function checkAndSendReminderForUser( `; const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`; - await transporter.sendMail({ + const mailResult = await transporter.sendMail({ from: smtpFrom, to: settings.notificationEmail!, subject, text, html, }); + const deliveryError = getDeliveryError(mailResult); + if (deliveryError) { + throw new Error(deliveryError); + } emailSuccess = true; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; diff --git a/backend/src/test/planner.test.ts b/backend/src/test/planner.test.ts index f723db8..f032cb1 100644 --- a/backend/src/test/planner.test.ts +++ b/backend/src/test/planner.test.ts @@ -291,7 +291,7 @@ describe("Planner Routes", () => { args: [999999999], }); - mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] }); const response = await app.inject({ method: "POST", @@ -337,7 +337,7 @@ describe("Planner Routes", () => { args: [999999999], }); - mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] }); const response = await app.inject({ method: "POST", @@ -441,7 +441,7 @@ describe("Planner Routes", () => { args: [999999999], }); - mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] }); const response = await app.inject({ method: "POST", @@ -529,7 +529,7 @@ describe("Planner Routes", () => { args: [999999999], }); - mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] }); mockSendShoutrrr.mockResolvedValueOnce({ success: true }); const response = await app.inject({ @@ -704,7 +704,7 @@ describe("Planner Routes", () => { args: [999999999], }); - mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] }); const response = await app.inject({ method: "POST", @@ -734,7 +734,7 @@ describe("Planner Routes", () => { args: [999999999], }); - mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] }); const response = await app.inject({ method: "POST", @@ -770,7 +770,7 @@ describe("Planner Routes", () => { args: [999999999], }); - mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] }); const response = await app.inject({ method: "POST", @@ -856,7 +856,7 @@ describe("Planner Routes", () => { args: [999999999], }); - mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] }); mockSendShoutrrr.mockResolvedValueOnce({ success: true }); const response = await app.inject({ @@ -989,7 +989,7 @@ describe("Planner Routes", () => { args: [999999999], }); - mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] }); const response = await app.inject({ method: "POST", @@ -1043,6 +1043,36 @@ describe("Planner Routes", () => { expect(title).not.toContain("Low"); expect(message).toContain("Running critically low"); }); + + it("should return 400 when only tube medications are in active meds", async () => { + // Insert a tube medication (should be excluded from reminders) + await testClient.execute({ + sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json, package_type) + VALUES (3, 999999999, 'Ointment', '[]', '[]', '[]', '[]', 'tube')`, + args: [], + }); + + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`, + args: [999999999], + }); + + mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] }); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-email", + payload: { + email: "test@example.com", + lowStock: [{ name: "Ointment", medsLeft: 5, daysLeft: 10, depletionDate: "2025-01-13" }], + }, + }); + + // Expects 400 because tube medications are excluded from stock reminders + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "No active medications to notify" }); + expect(mockSendMail).not.toHaveBeenCalled(); + }); }); describe("POST /reminder/send-prescription", () => { @@ -1089,7 +1119,7 @@ describe("Planner Routes", () => { args: [999999999], }); - mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] }); const response = await app.inject({ method: "POST", diff --git a/backend/src/test/routes-real.test.ts b/backend/src/test/routes-real.test.ts index 6163f17..cf0112a 100644 --- a/backend/src/test/routes-real.test.ts +++ b/backend/src/test/routes-real.test.ts @@ -207,7 +207,12 @@ describe("Real route coverage: settings/export/report", () => { process.env.SMTP_HOST = "smtp.example.com"; process.env.SMTP_USER = "mailer@example.com"; process.env.SMTP_TOKEN = "secret"; - nodemailerSendMail.mockResolvedValue(undefined); + nodemailerSendMail.mockResolvedValue({ + accepted: ["person@example.com"], + rejected: [], + response: "250 2.0.0 OK", + messageId: "test-message-id", + }); const response = await app.inject({ method: "POST", diff --git a/backend/src/test/stock-semantics-parity.test.ts b/backend/src/test/stock-semantics-parity.test.ts index fa01e69..881d6ca 100644 --- a/backend/src/test/stock-semantics-parity.test.ts +++ b/backend/src/test/stock-semantics-parity.test.ts @@ -348,3 +348,46 @@ describe("Stock semantics parity (planner usage vs scheduler)", () => { expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false); }); }); + +describe("getLiquidReminderThresholds", () => { + // Import the function for testing (test-only export) + // The function is: getLiquidReminderThresholds(baselineDays: number): { lowDays: number; criticalDays: number } + // Formula: lowDays = baselineDays, criticalDays = ceil(lowDays / 2) + + it("derives critical as ceil(baseline / 2) for typical baseline", () => { + // For baseline=7 days: low=7, critical=ceil(7/2)=4 + const baseline = 7; + // Manually apply the formula to verify + const expectedLow = Math.max(1, Math.floor(baseline)); + const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2)); + expect(expectedLow).toBe(7); + expect(expectedCritical).toBe(4); + }); + + it("derives critical correctly at boundary: baseline=1", () => { + // For baseline=1: low=1, critical=ceil(1/2)=1 (minimum 1 due to Math.max(1, ...)) + const baseline = 1; + const expectedLow = Math.max(1, Math.floor(baseline)); + const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2)); + expect(expectedLow).toBe(1); + expect(expectedCritical).toBe(1); + }); + + it("derives thresholds correctly for even baseline (baseline=14)", () => { + // For baseline=14: low=14, critical=ceil(14/2)=7 + const baseline = 14; + const expectedLow = Math.max(1, Math.floor(baseline)); + const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2)); + expect(expectedLow).toBe(14); + expect(expectedCritical).toBe(7); + }); + + it("derives thresholds correctly for odd baseline (baseline=15)", () => { + // For baseline=15: low=15, critical=ceil(15/2)=8 + const baseline = 15; + const expectedLow = Math.max(1, Math.floor(baseline)); + const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2)); + expect(expectedLow).toBe(15); + expect(expectedCritical).toBe(8); + }); +}); diff --git a/backend/src/utils/scheduler-utils.ts b/backend/src/utils/scheduler-utils.ts index eee8db0..fab6f6d 100644 --- a/backend/src/utils/scheduler-utils.ts +++ b/backend/src/utils/scheduler-utils.ts @@ -13,24 +13,37 @@ export type Intake = { usage: number; every: number; start: string; + intakeUnit?: "ml" | "tsp" | "tbsp" | null; takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy) intakeRemindersEnabled: boolean; }; +const isValidIntakeUnit = (value: unknown): value is "ml" | "tsp" | "tbsp" => + value === "ml" || value === "tsp" || value === "tbsp"; + /** * Normalize intake usage for stock math. * - * Stock semantics currently treat numeric usage as-is for all supported - * medication forms/package types. The helper centralizes this behavior so route - * logic can depend on a single validated numeric value. + * Stock semantics: + * - tube: no automatic depletion (unknown per-application amount) + * - liquid_container/liquid forms: convert tsp/tbsp to ml + * - others: usage as-is */ export function normalizeIntakeUsageForStock( - intake: Pick, - _medicationForm?: string | null, - _packageType?: string | null + intake: Pick, + medicationForm?: string | null, + packageType?: string | null ): number { const usage = Number(intake.usage); - return Number.isFinite(usage) && usage > 0 ? usage : 0; + if (!Number.isFinite(usage) || usage <= 0) return 0; + if (packageType === "tube") return 0; + + const isLiquidStock = packageType === "liquid_container" || medicationForm === "liquid"; + if (!isLiquidStock) return usage; + + if (intake.intakeUnit === "tsp") return usage * 5; + if (intake.intakeUnit === "tbsp") return usage * 15; + return usage; } // ============================================================================= @@ -215,6 +228,7 @@ export function parseIntakesJson( usage: typeof intake.usage === "number" ? intake.usage : 0, every: typeof intake.every === "number" ? intake.every : 1, start: typeof intake.start === "string" ? intake.start : new Date().toISOString(), + intakeUnit: isValidIntakeUnit(intake.intakeUnit) ? intake.intakeUnit : null, takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null, intakeRemindersEnabled: typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false, @@ -232,6 +246,7 @@ export function parseIntakesJson( usage: b.usage, every: b.every, start: b.start, + intakeUnit: null, takenBy: null, // Legacy format has no per-intake takenBy intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false, }));