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}` });
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Intake, "usage">,
|
||||
_medicationForm?: string | null,
|
||||
_packageType?: string | null
|
||||
intake: Pick<Intake, "usage" | "intakeUnit">,
|
||||
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,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user