fix: align backend amount stock and reminder semantics (#362)
* fix: align backend amount stock and reminder semantics * test: align settings email route success mock with SMTP delivery checks
This commit is contained in:
@@ -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<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,
|
||||
}));
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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)}`;
|
||||
</div>
|
||||
`;
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user