feat: obsolete medication archiving, start date, and UI improvements (#215)
* feat: obsolete medication archiving, start date, and UI improvements - Add soft-archive (obsolete) for medications with dedicated section and toggle - Add medication start date field with date picker and validation - Add obsolete/reactivate API endpoints with proper auth - Filter obsolete meds from schedule, coverage, planner, and notifications - Improve UserFilterModal with intake schedules, stock badges, and click-to-open - Improve dashboard taken-by badges with per-intake bell icons - Add Escape key support to ConfirmModal and MobileEditModal - Fix Lightbox close button positioning near image - Add read-only mode support for MobileEditModal - DB migrations: 0008 (is_obsolete, obsolete_at), 0009 (medication_start_date) - All user-facing text uses i18n keys (en + de) * test: fix tests for obsolete medications and UI changes - Backend: add is_obsolete, obsolete_at, medication_start_date columns to test schemas - Backend: add test medication inserts in planner tests for active-med filtering - Frontend: update useMedications URL to include includeObsolete param - Frontend: fix MobileEditModal selectors and validation assertions - Frontend: add onClearUser prop to UserFilterModal test renders - Frontend: fix MedicationsPage and DashboardPage test assertions
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `medications` ADD `is_obsolete` integer DEFAULT false NOT NULL;
|
||||
ALTER TABLE `medications` ADD `obsolete_at` integer;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `medications` ADD `medication_start_date` text DEFAULT '' NOT NULL;
|
||||
@@ -57,6 +57,20 @@
|
||||
"when": 1770659669121,
|
||||
"tag": "0007_add_share_stock_status",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1771160400000,
|
||||
"tag": "0008_add_obsolete_medications",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1771164000000,
|
||||
"tag": "0009_add_medication_start_date",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -119,6 +119,11 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
||||
// Added in v1.5.1 - dismiss past doses until date (robust against timestamp changes)
|
||||
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
|
||||
// Added for soft-archiving medications (without deleting history)
|
||||
`ALTER TABLE medications ADD COLUMN is_obsolete integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
|
||||
// Added for explicit medication lifecycle start date
|
||||
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
|
||||
// Added for more detailed reminder info display
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
|
||||
|
||||
@@ -47,6 +47,9 @@ export const medications = sqliteTable("medications", {
|
||||
expiryDate: text("expiry_date"),
|
||||
notes: text("notes"),
|
||||
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
medicationStartDate: text("medication_start_date").notNull().default(""),
|
||||
isObsolete: integer("is_obsolete", { mode: "boolean" }).notNull().default(false),
|
||||
obsoleteAt: integer("obsolete_at", { mode: "timestamp" }),
|
||||
prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
prescriptionAuthorizedRefills: integer("prescription_authorized_refills"),
|
||||
prescriptionRemainingRefills: integer("prescription_remaining_refills"),
|
||||
|
||||
@@ -49,9 +49,12 @@ const medicationExportSchema = z.object({
|
||||
pillWeightMg: z.number().int().nullable().optional(),
|
||||
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
|
||||
schedules: z.array(scheduleSchema).default([]),
|
||||
medicationStartDate: z.string().nullable().optional(),
|
||||
expiryDate: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
intakeRemindersEnabled: z.boolean().default(false),
|
||||
isObsolete: z.boolean().default(false),
|
||||
obsoleteAt: z.string().nullable().optional(),
|
||||
prescriptionEnabled: z.boolean().default(false),
|
||||
prescriptionAuthorizedRefills: z.number().int().min(0).nullable().optional(),
|
||||
prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(),
|
||||
@@ -289,9 +292,12 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
pillWeightMg: med.pillWeightMg,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
schedules: parseIntakesForExport(med),
|
||||
medicationStartDate: med.medicationStartDate || null,
|
||||
expiryDate: med.expiryDate,
|
||||
notes: med.notes,
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
isObsolete: med.isObsolete ?? false,
|
||||
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
|
||||
@@ -515,6 +521,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
||||
pillWeightMg: med.pillWeightMg || null,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
medicationStartDate: med.medicationStartDate || "",
|
||||
intakesJson,
|
||||
usageJson,
|
||||
everyJson,
|
||||
@@ -522,6 +529,8 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
expiryDate: med.expiryDate || null,
|
||||
notes: med.notes || null,
|
||||
intakeRemindersEnabled,
|
||||
isObsolete: med.isObsolete ?? false,
|
||||
obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionEnabled ? (med.prescriptionAuthorizedRefills ?? null) : null,
|
||||
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
|
||||
|
||||
@@ -32,6 +32,9 @@ const blisterSchema = z.object({
|
||||
|
||||
const packageTypeSchema = z.enum(["blister", "bottle"]).default("blister");
|
||||
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
|
||||
const medicationStartDateSchema = z
|
||||
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
|
||||
.optional();
|
||||
|
||||
const medicationSchema = z
|
||||
.object({
|
||||
@@ -46,6 +49,7 @@ const medicationSchema = z
|
||||
looseTablets: z.number().int().min(0).default(0),
|
||||
pillWeightMg: z.number().nonnegative().nullable().optional(),
|
||||
doseUnit: doseUnitSchema,
|
||||
medicationStartDate: medicationStartDateSchema,
|
||||
expiryDate: z.string().nullable().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
prescriptionEnabled: z.boolean().default(false),
|
||||
@@ -59,6 +63,19 @@ const medicationSchema = z
|
||||
blisters: z.array(blisterSchema).min(1).max(12).optional(), // Legacy format
|
||||
})
|
||||
.refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" })
|
||||
.refine(
|
||||
(data) => {
|
||||
const startDate = data.medicationStartDate ?? "";
|
||||
if (!startDate) return true;
|
||||
|
||||
const scheduleStarts = data.intakes?.map((i) => i.start) ?? data.blisters?.map((b) => b.start) ?? [];
|
||||
return scheduleStarts.every((scheduleStart) => scheduleStart.slice(0, 10) >= startDate);
|
||||
},
|
||||
{
|
||||
message: "Medication start date must be on or before all intake dates",
|
||||
path: ["medicationStartDate"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.prescriptionEnabled) return true;
|
||||
@@ -103,9 +120,13 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
app.get("/medications", async (request, reply) => {
|
||||
app.get<{ Querystring: { includeObsolete?: string } }>("/medications", async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const includeObsolete = request.query.includeObsolete === "true";
|
||||
const whereClause = includeObsolete
|
||||
? eq(medications.userId, userId)
|
||||
: and(eq(medications.userId, userId), eq(medications.isObsolete, false));
|
||||
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(
|
||||
@@ -129,6 +150,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: row.pillWeightMg,
|
||||
doseUnit: row.doseUnit ?? "mg",
|
||||
medicationStartDate: row.medicationStartDate || null,
|
||||
intakes, // New unified format with per-intake takenBy
|
||||
// Legacy blisters format (for backward compat with frontend during transition)
|
||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||
@@ -136,6 +158,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
expiryDate: row.expiryDate,
|
||||
notes: row.notes,
|
||||
intakeRemindersEnabled: row.intakeRemindersEnabled ?? false,
|
||||
isObsolete: row.isObsolete ?? false,
|
||||
obsoleteAt: row.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: row.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: row.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: row.prescriptionRemainingRefills ?? null,
|
||||
@@ -164,6 +188,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg,
|
||||
doseUnit,
|
||||
medicationStartDate,
|
||||
expiryDate,
|
||||
notes,
|
||||
prescriptionEnabled,
|
||||
@@ -222,6 +247,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg: pillWeightMg || null,
|
||||
doseUnit: doseUnit ?? "mg",
|
||||
medicationStartDate: medicationStartDate ?? "",
|
||||
expiryDate: expiryDate || null,
|
||||
notes: notes || null,
|
||||
prescriptionEnabled: prescriptionEnabled ?? false,
|
||||
@@ -252,12 +278,15 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: inserted.pillWeightMg,
|
||||
doseUnit: inserted.doseUnit ?? "mg",
|
||||
medicationStartDate: inserted.medicationStartDate || null,
|
||||
intakes,
|
||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||
imageUrl: inserted.imageUrl,
|
||||
expiryDate: inserted.expiryDate,
|
||||
notes: inserted.notes,
|
||||
intakeRemindersEnabled: inserted.intakeRemindersEnabled,
|
||||
isObsolete: inserted.isObsolete ?? false,
|
||||
obsoleteAt: inserted.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: inserted.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: inserted.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: inserted.prescriptionRemainingRefills ?? null,
|
||||
@@ -294,6 +323,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg,
|
||||
doseUnit,
|
||||
medicationStartDate,
|
||||
expiryDate,
|
||||
notes,
|
||||
prescriptionEnabled,
|
||||
@@ -362,6 +392,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg: pillWeightMg || null,
|
||||
doseUnit: doseUnit ?? "mg",
|
||||
medicationStartDate: medicationStartDate ?? "",
|
||||
expiryDate: expiryDate || null,
|
||||
notes: notes || null,
|
||||
prescriptionEnabled: prescriptionEnabled ?? false,
|
||||
@@ -516,12 +547,15 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: result[0].pillWeightMg,
|
||||
doseUnit: result[0].doseUnit ?? "mg",
|
||||
medicationStartDate: result[0].medicationStartDate || null,
|
||||
intakes,
|
||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||
imageUrl: result[0].imageUrl,
|
||||
expiryDate: result[0].expiryDate,
|
||||
notes: result[0].notes,
|
||||
intakeRemindersEnabled: result[0].intakeRemindersEnabled,
|
||||
isObsolete: result[0].isObsolete ?? false,
|
||||
obsoleteAt: result[0].obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: result[0].prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: result[0].prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: result[0].prescriptionRemainingRefills ?? null,
|
||||
@@ -531,6 +565,64 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>("/medications/:id/obsolete", 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 [existing] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
const [updated] = await db
|
||||
.update(medications)
|
||||
.set({
|
||||
isObsolete: true,
|
||||
obsoleteAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
isObsolete: updated.isObsolete ?? false,
|
||||
obsoleteAt: updated.obsoleteAt?.toISOString() ?? null,
|
||||
updatedAt: updated.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>("/medications/:id/reactivate", 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 [existing] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
const [updated] = await db
|
||||
.update(medications)
|
||||
.set({
|
||||
isObsolete: false,
|
||||
obsoleteAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
isObsolete: updated.isObsolete ?? false,
|
||||
obsoleteAt: updated.obsoleteAt?.toISOString() ?? null,
|
||||
updatedAt: updated.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Stock correction endpoint - only updates stockAdjustment, preserves looseTablets
|
||||
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
|
||||
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number } }>(
|
||||
@@ -678,7 +770,11 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
// Get all taken doses for this user to calculate actual consumption
|
||||
const takenDoses = await db
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
@@ -103,6 +103,16 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
|
||||
// Load user settings for notification channels
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
.select({ id: medications.id })
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const activeMedIds = new Set(activeMeds.map((med) => med.id));
|
||||
const activeRows = rows.filter((row) => activeMedIds.has(row.medicationId));
|
||||
if (activeRows.length === 0) {
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const notificationSettings = {
|
||||
emailEnabled: userSettings.emailEnabled,
|
||||
@@ -132,11 +142,11 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
})
|
||||
);
|
||||
|
||||
const outOfStockCount = rows.filter((r) => !r.enough).length;
|
||||
const outOfStockCount = activeRows.filter((r) => !r.enough).length;
|
||||
const summaryText = outOfStockCount > 0 ? t(dc.summaryOutOfStock, { count: outOfStockCount }) : dc.summaryAllOk;
|
||||
|
||||
// Load prescription data for medications referenced in planner rows
|
||||
const medIds = rows.map((r) => r.medicationId).filter(Boolean);
|
||||
const medIds = activeRows.map((r) => r.medicationId).filter(Boolean);
|
||||
const allMeds =
|
||||
medIds.length > 0
|
||||
? await db
|
||||
@@ -156,7 +166,7 @@ ${t(dc.description, { from: fromDate, until: untilDate })}
|
||||
|
||||
${summaryText}
|
||||
|
||||
${rows
|
||||
${activeRows
|
||||
.map((r) => {
|
||||
const isBottle = r.packageType === "bottle";
|
||||
const usage = `${r.plannerUsage} ${tr.common.pills}`;
|
||||
@@ -191,7 +201,7 @@ ${getFooterPlain(language)}`;
|
||||
if (smtpHost && smtpUser) {
|
||||
// Build HTML table with horizontal scroll for mobile
|
||||
// Escape/coerce all user-provided values to prevent XSS
|
||||
const tableRows = rows
|
||||
const tableRows = activeRows
|
||||
.map((row) => {
|
||||
const safeName = escapeHtml(row.medicationName);
|
||||
const safePlannerUsage = Number(row.plannerUsage) || 0;
|
||||
@@ -312,7 +322,7 @@ ${getFooterPlain(language)}`;
|
||||
// Send push notification if enabled
|
||||
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
||||
const pushTitle = t(dc.subject, { from: fromDate, until: untilDate });
|
||||
const pushMessage = `${summaryText}\n\n${rows
|
||||
const pushMessage = `${summaryText}\n\n${activeRows
|
||||
.map((r) => {
|
||||
const usage = `${r.plannerUsage} ${tr.common.pills}`;
|
||||
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
|
||||
@@ -360,6 +370,16 @@ ${getFooterPlain(language)}`;
|
||||
|
||||
// Load user settings
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
.select({ name: medications.name })
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const activeMedNames = new Set(activeMeds.map((med) => med.name));
|
||||
const filteredLowStock = lowStock.filter((item) => activeMedNames.has(item.name));
|
||||
if (filteredLowStock.length === 0) {
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const notificationSettings = {
|
||||
emailEnabled: userSettings.emailEnabled,
|
||||
@@ -374,9 +394,9 @@ ${getFooterPlain(language)}`;
|
||||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||||
|
||||
// Separate into 3 categories: empty, critical, and low stock
|
||||
const emptyMeds = lowStock.filter((r) => r.medsLeft <= 0);
|
||||
const criticalMeds = lowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false);
|
||||
const lowStockMeds = lowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false);
|
||||
const emptyMeds = filteredLowStock.filter((r) => r.medsLeft <= 0);
|
||||
const criticalMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false);
|
||||
const lowStockMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false);
|
||||
|
||||
// Build shared notification content (method-agnostic)
|
||||
const titleParts: string[] = [];
|
||||
@@ -504,7 +524,7 @@ ${getFooterPlain(language)}`;
|
||||
</tr>`;
|
||||
};
|
||||
|
||||
const tableRows = lowStock.map(buildTableRow).join("");
|
||||
const tableRows = filteredLowStock.map(buildTableRow).join("");
|
||||
|
||||
const html = `
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||
@@ -587,8 +607,7 @@ ${getFooterPlain(language)}`;
|
||||
updateReminderSentTime("stock", channel);
|
||||
|
||||
// Also update user settings in database so frontend can display the info
|
||||
const firstMed = lowStock[0];
|
||||
const medNames = lowStock.map((m: { name: string }) => m.name).join(", ");
|
||||
const medNames = filteredLowStock.map((m: { name: string }) => m.name).join(", ");
|
||||
await updateUserReminderSentTime(userId, "stock", channel, medNames);
|
||||
}
|
||||
|
||||
@@ -618,14 +637,24 @@ ${getFooterPlain(language)}`;
|
||||
}
|
||||
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
.select({ name: medications.name })
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const activeMedNames = new Set(activeMeds.map((med) => med.name));
|
||||
const filteredPrescriptionLow = prescriptionLow.filter((item) => activeMedNames.has(item.name));
|
||||
if (filteredPrescriptionLow.length === 0) {
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const language = (userSettings.language as Language) || "en";
|
||||
const tr = getTranslations(language);
|
||||
|
||||
const emptyRx = prescriptionLow.filter((item) => item.remainingRefills <= 0);
|
||||
const lowRx = prescriptionLow.filter((item) => item.remainingRefills > 0);
|
||||
const emptyRx = filteredPrescriptionLow.filter((item) => item.remainingRefills <= 0);
|
||||
const lowRx = filteredPrescriptionLow.filter((item) => item.remainingRefills > 0);
|
||||
|
||||
const lines = prescriptionLow.map((item) => {
|
||||
const lines = filteredPrescriptionLow.map((item) => {
|
||||
const expirySuffix = item.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: item.expiryDate }) : "";
|
||||
if (item.remainingRefills <= 0) {
|
||||
return `- ${t(tr.prescriptionReminder.lineEmpty, {
|
||||
@@ -640,7 +669,7 @@ ${getFooterPlain(language)}`;
|
||||
})}`;
|
||||
});
|
||||
|
||||
const medNames = prescriptionLow.map((m: { name: string }) => m.name).join(", ");
|
||||
const medNames = filteredPrescriptionLow.map((m: { name: string }) => m.name).join(", ");
|
||||
|
||||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||||
|
||||
@@ -665,9 +694,9 @@ ${getFooterPlain(language)}`;
|
||||
});
|
||||
|
||||
const subject =
|
||||
prescriptionLow.length === 1
|
||||
filteredPrescriptionLow.length === 1
|
||||
? tr.prescriptionReminder.subjectSingle
|
||||
: t(tr.prescriptionReminder.subjectMultiple, { count: prescriptionLow.length });
|
||||
: t(tr.prescriptionReminder.subjectMultiple, { count: filteredPrescriptionLow.length });
|
||||
|
||||
const bodyText =
|
||||
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
|
||||
@@ -680,7 +709,7 @@ ${getFooterPlain(language)}`;
|
||||
? tr.prescriptionReminder.alertLowSingle
|
||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||
|
||||
const tableRows = prescriptionLow
|
||||
const tableRows = filteredPrescriptionLow
|
||||
.map((item) => {
|
||||
const isEmpty = item.remainingRefills <= 0;
|
||||
const safeName = escapeHtml(item.name);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
@@ -144,7 +144,11 @@ async function getMedicationsNeedingReminder(
|
||||
lowStockDays: number,
|
||||
language: Language
|
||||
): Promise<LowStockItem[]> {
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
const lowStock: LowStockItem[] = [];
|
||||
|
||||
@@ -176,7 +180,11 @@ async function getMedicationsNeedingReminder(
|
||||
}
|
||||
|
||||
async function getMedicationsNeedingPrescriptionReminder(userId: number): Promise<PrescriptionReminderItem[]> {
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
return rows
|
||||
.filter(
|
||||
|
||||
@@ -99,6 +99,9 @@ async function createSchema(client: Client) {
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_authorized_refills integer,
|
||||
prescription_remaining_refills integer,
|
||||
|
||||
@@ -94,6 +94,9 @@ async function createSchema(client: Client) {
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_authorized_refills integer,
|
||||
prescription_remaining_refills integer,
|
||||
|
||||
@@ -111,6 +111,9 @@ async function createSchema(client: Client) {
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_authorized_refills integer,
|
||||
prescription_remaining_refills integer,
|
||||
@@ -168,6 +171,7 @@ async function createSchema(client: Client) {
|
||||
}
|
||||
|
||||
async function clearData(client: Client) {
|
||||
await client.execute("DELETE FROM medications");
|
||||
await client.execute("DELETE FROM user_settings");
|
||||
await client.execute("DELETE FROM users");
|
||||
await client.execute("DELETE FROM sqlite_sequence");
|
||||
@@ -188,6 +192,18 @@ describe("Planner Routes", () => {
|
||||
"INSERT INTO users (id, username, auth_provider) VALUES (999999999, '__anonymous__', 'anonymous')"
|
||||
);
|
||||
|
||||
// Insert test medications so active-medication filters pass
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json)
|
||||
VALUES (1, 999999999, 'Aspirin', '["Daniel"]', '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]')`,
|
||||
args: [],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json)
|
||||
VALUES (2, 999999999, 'Ibuprofen', '["Daniel"]', '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]')`,
|
||||
args: [],
|
||||
});
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(plannerRoutes);
|
||||
await app.ready();
|
||||
|
||||
Reference in New Issue
Block a user